diff --git a/.gitignore b/.gitignore index db6280652..98a65bcaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,40 @@ -*.class -*.jar -*.war -*.ear -.classpath -.project -.settings -.gradle -build -*.iml -*.ipr -*.iws -*.swp -*.DS_Store -.idea -.vscode -catalina.base_IS_UNDEFINED/ -keystore.jks -bin/ -.lock -fineract-provider/out/ -fineract-provider/config/swagger/config.json -fineract-provider/config/swagger/fineract-input.yaml -licenses -**/.asciidoctor/images/* -**/.asciidoctor/diagram/* -**/images/diag-*.svg - -fineract-provider/src/main/generated/ - -**/out/ -gradleExp/ - -.run/ -.java-version - -.windsurf/ - -# Application logs +*.class +*.jar +*.war +*.ear +.classpath +.project +.settings +.gradle +build +*.iml +*.ipr +*.iws +*.swp +*.DS_Store +.idea +.vscode +catalina.base_IS_UNDEFINED/ +keystore.jks +bin/ +.lock +fineract-provider/out/ +fineract-provider/config/swagger/config.json +fineract-provider/config/swagger/fineract-input.yaml +licenses +**/.asciidoctor/images/* +**/.asciidoctor/diagram/* +**/images/diag-*.svg + +fineract-provider/src/main/generated/ + +**/out/ +gradleExp/ + +.run/ +.java-version + +.windsurf/ + +# Application logs fineract-provider/logs/ \ No newline at end of file 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/logs/bootrun.out b/fineract-provider/logs/bootrun.out deleted file mode 100644 index aa8c18ef6..000000000 --- a/fineract-provider/logs/bootrun.out +++ /dev/null @@ -1,975 +0,0 @@ -> Task :buildSrc:extractPluginRequests UP-TO-DATE -> Task :buildSrc:generatePluginAdapters UP-TO-DATE -> Task :buildSrc:compileJava UP-TO-DATE -> Task :buildSrc:compileGroovy UP-TO-DATE -> Task :buildSrc:compileGroovyPlugins UP-TO-DATE -> Task :buildSrc:pluginDescriptors UP-TO-DATE -> Task :buildSrc:processResources UP-TO-DATE -> Task :buildSrc:classes UP-TO-DATE -> Task :buildSrc:jar UP-TO-DATE - -> Configure project : -matching ref: COMMIT - 7dea88d30f72365945bed2160d2ba72dccdc80b7 - ref configuration: COMMIT - pattern: null - version: ${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT - describeTagPattern: .*(\d+\.\d+\.\d+).* - describeTagFirstParent: false - -project version: 1.15.0-SNAPSHOT - -> Task :spotlessInternalRegisterDependencies UP-TO-DATE -> Task :fineract-current-product:processResources NO-SOURCE -> Task :fineract-mix:processResources NO-SOURCE -> Task :fineract-rates:processResources UP-TO-DATE -> Task :fineract-core:processResources UP-TO-DATE -> Task :fineract-cob:processResources UP-TO-DATE -> Task :fineract-branch:processResources UP-TO-DATE -> Task :fineract-security:processResources NO-SOURCE -> Task :fineract-loan-origination:processResources UP-TO-DATE -> Task :fineract-savings:processResources UP-TO-DATE -> Task :fineract-accounting:processResources UP-TO-DATE -> Task :fineract-report:processResources UP-TO-DATE -> Task :fineract-charge:processResources UP-TO-DATE -> Task :fineract-investor:processResources UP-TO-DATE -> Task :fineract-loan:processResources UP-TO-DATE -> Task :fineract-client:processResources UP-TO-DATE -> Task :fineract-progressive-loan:processResources UP-TO-DATE -> Task :fineract-working-capital-loan:processResources UP-TO-DATE -> Task :fineract-document:processResources NO-SOURCE -> Task :fineract-tax:processResources UP-TO-DATE -> Task :fineract-avro-schemas:preprocessAvroSchemas UP-TO-DATE -> Task :fineract-validation:compileJava UP-TO-DATE -> Task :fineract-command:compileJava UP-TO-DATE -> Task :fineract-validation:processResources UP-TO-DATE -> Task :fineract-validation:classes UP-TO-DATE -> Task :fineract-command:processResources UP-TO-DATE -> Task :fineract-command:classes UP-TO-DATE -> Task :fineract-command:jar UP-TO-DATE -> Task :fineract-validation:jar UP-TO-DATE -> Task :fineract-avro-schemas:spotlessJson UP-TO-DATE -> Task :fineract-avro-schemas:spotlessJsonApply UP-TO-DATE -> Task :fineract-avro-schemas:buildJavaSdk UP-TO-DATE -> Task :fineract-avro-schemas:compileJava UP-TO-DATE -> Task :fineract-avro-schemas:processResources UP-TO-DATE -> Task :fineract-avro-schemas:classes UP-TO-DATE -> Task :fineract-avro-schemas:jar UP-TO-DATE -> Task :fineract-working-capital-loan:compileJava UP-TO-DATE -> Task :fineract-working-capital-loan:classes UP-TO-DATE -> Task :fineract-working-capital-loan:jar UP-TO-DATE -> Task :fineract-core:compileJava UP-TO-DATE -> Task :fineract-core:classes UP-TO-DATE -> Task :fineract-core:jar UP-TO-DATE -> Task :fineract-mix:compileJava UP-TO-DATE -> Task :fineract-tax:compileJava UP-TO-DATE -> Task :fineract-mix:classes UP-TO-DATE -> Task :fineract-report:compileJava UP-TO-DATE -> Task :fineract-tax:classes UP-TO-DATE -> Task :fineract-mix:jar UP-TO-DATE -> Task :fineract-rates:compileJava UP-TO-DATE -> Task :fineract-report:classes UP-TO-DATE -> Task :fineract-cob:compileJava UP-TO-DATE -> Task :fineract-rates:classes UP-TO-DATE -> Task :fineract-cob:classes UP-TO-DATE -> Task :fineract-tax:jar UP-TO-DATE -> Task :fineract-report:jar UP-TO-DATE -> Task :fineract-cob:jar UP-TO-DATE -> Task :fineract-rates:jar UP-TO-DATE -> Task :fineract-document:compileJava UP-TO-DATE -> Task :fineract-document:classes UP-TO-DATE -> Task :fineract-security:compileJava UP-TO-DATE -> Task :fineract-document:jar UP-TO-DATE -> Task :fineract-security:classes UP-TO-DATE -> Task :fineract-security:jar UP-TO-DATE -> Task :fineract-charge:compileJava UP-TO-DATE -> Task :fineract-charge:classes UP-TO-DATE -> Task :fineract-charge:jar UP-TO-DATE -> Task :fineract-accounting:compileJava UP-TO-DATE -> Task :fineract-accounting:classes UP-TO-DATE -> Task :fineract-accounting:jar UP-TO-DATE -> Task :fineract-branch:compileJava UP-TO-DATE -> Task :fineract-branch:classes UP-TO-DATE -> Task :fineract-loan:compileJava UP-TO-DATE -> Task :fineract-loan:classes UP-TO-DATE -> Task :fineract-branch:jar UP-TO-DATE -> Task :fineract-loan:jar UP-TO-DATE -> Task :fineract-savings:compileJava UP-TO-DATE -> Task :fineract-savings:classes UP-TO-DATE -> Task :fineract-savings:jar UP-TO-DATE -> Task :fineract-loan-origination:compileJava UP-TO-DATE -> Task :fineract-investor:compileJava UP-TO-DATE -> Task :fineract-progressive-loan:compileJava UP-TO-DATE -> Task :fineract-loan-origination:classes UP-TO-DATE -> Task :fineract-progressive-loan:classes UP-TO-DATE -> Task :fineract-investor:classes UP-TO-DATE -> Task :fineract-progressive-loan:jar UP-TO-DATE -> Task :fineract-loan-origination:jar UP-TO-DATE -> Task :fineract-investor:jar UP-TO-DATE -> Task :fineract-current-product:compileJava UP-TO-DATE -> Task :fineract-current-product:classes UP-TO-DATE -> Task :fineract-current-product:jar UP-TO-DATE -> Task :fineract-provider:generateGitProperties -> Task :fineract-provider:compileJava UP-TO-DATE -> Task :fineract-provider:generateRebelMain UP-TO-DATE -> Task :fineract-provider:processResources -> Task :fineract-provider:classes -> Task :fineract-provider:compileTestJava UP-TO-DATE -> Task :fineract-provider:prepareInputYaml UP-TO-DATE -> Task :fineract-provider:processTestResources UP-TO-DATE -> Task :fineract-provider:testClasses UP-TO-DATE -> Task :fineract-provider:resolve -> Task :fineract-provider:resolveMainClassName - -> Task :fineract-provider:bootRun -19:17:05,132 |-WARN in ch.qos.logback.core.joran.action.ConversionRuleAction - [converterClass] attribute is deprecated and replaced by [class]. See element [conversionRule] near line 30 - - _ _ _____ _ _ - / \ _ __ __ _ ___| |__ ___ | ___(_)_ __ ___ _ __ __ _ ___| |_ - / _ \ | '_ \ / _` |/ __| '_ \ / _ \ | |_ | | '_ \ / _ \ '__/ _` |/ __| __| - / ___ \| |_) | (_| | (__| | | | __/ | _| | | | | | __/ | | (_| | (__| |_ -/_/ \_\ .__/ \__,_|\___|_| |_|\___| |_| |_|_| |_|\___|_| \__,_|\___|\__| - |_| - -(c) 2015-2024 Apache Fineract (https://fineract.apache.org) - -Powered by Spring Boot 3.5.6 - - -2026-05-29 19:17:05.618 - INFO 7760  [ no-tenant] --- [ main] org.apache.fineract.ServerApplication  : Starting ServerApplication using Java 21.0.9 with PID 7760 (C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main started by adity in C:\Users\adity\Music\CBS\fineract\fineract-provider) -2026-05-29 19:17:05.622 - INFO 7760  [ no-tenant] --- [ main] org.apache.fineract.ServerApplication  : No active profile set, falling back to 1 default profile: "default" -2026-05-29 19:17:10.836 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode -2026-05-29 19:17:10.836 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode. -2026-05-29 19:17:11.794 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.797 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.811 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixReportXBRLNamespaceRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.813 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixTaxonomyMappingRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.815 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.mix.domain.MixTaxonomyRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:11.887 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JPA - Could not safely identify store assignment for repository candidate interface org.apache.fineract.command.persistence.domain.CommandRepository; If you want this repository to be a JPA repository, consider annotating your entities with one of these annotations: jakarta.persistence.Entity, jakarta.persistence.MappedSuperclass (preferred), or consider extending one of the following types with your repository: org.springframework.data.jpa.repository.JpaRepository -2026-05-29 19:17:13.120 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 2267 ms. Found 224 JPA repository interfaces. -2026-05-29 19:17:13.130 - INFO 7760  [ no-tenant] --- [ main] o.s.b.f.s.DefaultListableBeanFactory  : Overriding bean definition for bean 'jpaAuditingHandler' with a different definition: replacing [Root bean: class=org.springframework.data.auditing.AuditingHandler; scope=; abstract=false; lazyInit=null; autowireMode=2; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=from; initMethodNames=null; destroyMethodNames=null] with [Root bean: class=org.apache.fineract.infrastructure.core.auditing.CustomAuditingHandler; scope=prototype; abstract=false; lazyInit=null; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=null; initMethodNames=null; destroyMethodNames=null] -2026-05-29 19:17:13.204 - INFO 7760  [ no-tenant] --- [ main] o.s.b.f.s.DefaultListableBeanFactory  : Overriding bean definition for bean 'transactionManager' with a different definition: replacing [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=JPAConfig; factoryMethodName=transactionManager; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [org/apache/fineract/infrastructure/core/config/jpa/JPAConfig.class]] with [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=scheduledJobRunnerConfig; factoryMethodName=transactionManager; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [org/apache/fineract/infrastructure/jobs/ScheduledJobRunnerConfig.class]] -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobRepository already defined in the application context, skipping the registration of a jobRepository -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobExplorer already defined in the application context, skipping the registration of a jobExplorer -2026-05-29 19:17:13.206 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Bean jobLauncher already defined in the application context, skipping the registration of a jobLauncher -2026-05-29 19:17:13.208 - INFO 7760  [ no-tenant] --- [ main] o.s.b.c.c.annotation.BatchRegistrar  : Finished Spring Batch infrastructure beans configuration in 3 ms. -2026-05-29 19:17:14.107 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode -2026-05-29 19:17:14.108 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JDBC repositories in DEFAULT mode. -2026-05-29 19:17:14.408 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.provisioning.domain.ProvisioningEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.409 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.adhocquery.domain.AdHocRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.409 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.cob.domain.LoanAccountLockRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.bulkimport.domain.ImportDocumentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailCampaignRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.email.domain.EmailMessageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.campaigns.sms.domain.SmsCampaignRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.410 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.configuration.domain.ExternalServicesPropertiesRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauLoanProductMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.412 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditBureauRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.CreditReportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.OrganisationCreditBureauRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.413 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.creditbureau.domain.TokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.EntityDatatableChecksRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.RegisteredDatatableRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportParameterUsageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.414 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.dataqueries.domain.ReportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityAccessRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityRelationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.entityaccess.domain.FineractEntityToEntityMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.415 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.hooks.domain.HookTemplateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.imps.domain.ImpsTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.JobParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.416 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.ScheduledJobRunHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.domain.SchedulerDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.service.aggregationjob.domain.JournalEntryAggregationTrackingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.jobs.service.aggregationjob.domain.JournalEntrySummaryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.417 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.419 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.419 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.reportmailingjob.domain.ReportMailingJobRunHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.420 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.sms.domain.SmsMessageRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.survey.domain.LikelihoodRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.interoperation.domain.InteropIdentifierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.421 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.notification.domain.NotificationMapperRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.notification.domain.NotificationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.holiday.domain.HolidayRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.422 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.office.domain.OfficeTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCategoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCriteriaDefinitionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.423 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.provisioning.domain.ProvisioningCriteriaRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.staff.domain.StaffRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.424 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountAssociationsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.425 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.425 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.AccountTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.address.domain.AddressRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.426 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.address.domain.FieldConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.calendar.domain.CalendarRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.ckyc.domain.CkycBatchRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientAddressRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.427 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientNonPersonRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.428 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientTransferDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.429 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateralmanagement.domain.ClientCollateralManagementRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.430 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateralmanagement.domain.CollateralManagementDomainRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.430 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.collateral.domain.LoanCollateralRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.BankIfscRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.CustomerTransferLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.431 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.fund.domain.FundRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupLevelRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.432 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.group.domain.GroupRoleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorFundingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorFundingTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.guarantor.domain.GuarantorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.433 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanproduct.productmix.domain.ProductMixRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.meeting.attendance.domain.ClientAttendanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.meeting.domain.MeetingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.note.domain.NoteRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.rate.domain.RateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.434 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.435 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.438 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdInterestRateEffectiveRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RdInterestRateSlabHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.account.domain.SelfBeneficiariesTPTRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.device.domain.DeviceRegistrationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.439 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.pockets.domain.PocketAccountMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.pockets.domain.PocketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.self.registration.domain.SelfServiceRegistrationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.440 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividentPayOutDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.441 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.441 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.LookupTableRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.442 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.ScorecardRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.444 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.spm.domain.SurveyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.444 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.template.domain.TemplateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.445 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.AppUserPreviousPasswordRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.445 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.AppUserRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.PasswordValidationPolicyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.PermissionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.domain.RoleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerJournalEntryMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.446 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributesRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferJournalEntryMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferLoanMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.447 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.investor.domain.ExternalAssetOwnerTransferRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.448 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketMappingsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.449 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanAccrualActivityRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.450 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidByRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagementRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetailsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.452 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.453 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanRepaymentScheduleHistoryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanRescheduleRequestRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanTermVariationsRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.454 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDepositLimitKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.455 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.456 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfigRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.cob.domain.BatchBusinessStepRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.CashierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.457 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.TellerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.teller.domain.TellerTransactionRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.closure.domain.GLClosureRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.glaccount.domain.TrialBalanceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.458 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.rule.domain.AccountingRuleRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.floatingrates.domain.FloatingRateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.459 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.charge.domain.ChargeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.tax.domain.TaxGroupRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.462 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.security.domain.TwoFactorConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.464 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.commands.domain.CommandSourceRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.businessdate.domain.BusinessDateRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.cache.domain.PlatformCacheRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.codes.domain.CodeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.466 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.infrastructure.rbifeed.domain.RbiFeedItemRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.monetary.domain.ApplicationCurrencyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.monetary.domain.OrganisationCurrencyRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.organisation.office.domain.OfficeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.CfcfrmsSimulatorLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.467 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.ComplaintAuditLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.468 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.cfcfrms.domain.ComplaintTicketRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.468 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientForm60Repository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.470 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientIdentifierRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.470 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientKycRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientNomineeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.domain.ClientRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientEouRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientImportExportRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientMsmeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.471 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientMudraBorrowerRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientNonPrioritySectorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientPrioritySectorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.client.entityclassification.domain.ClientStartupIndiaRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.paymenttype.domain.PaymentTypeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.472 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.sanctions.domain.SanctionsEntryRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.473 - INFO 7760  [ no-tenant] --- [ main] .RepositoryConfigurationExtensionSupport : Spring Data JDBC - Could not safely identify store assignment for repository candidate interface org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; If you want this repository to be a JDBC repository, consider annotating your entities with one of these annotations: org.springframework.data.relational.core.mapping.Table. -2026-05-29 19:17:14.768 - INFO 7760  [ no-tenant] --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 657 ms. Found 6 JDBC repository interfaces. -2026-05-29 19:17:16.886 - INFO 7760  [ no-tenant] --- [ main] faultConfiguringBeanFactoryPostProcessor : No bean named 'errorChannel' has been explicitly defined. Therefore, a default PublishSubscribeChannel will be created. -2026-05-29 19:17:16.895 - INFO 7760  [ no-tenant] --- [ main] faultConfiguringBeanFactoryPostProcessor : No bean named 'integrationHeaderChannelRegistry' has been explicitly defined. Therefore, a default DefaultHeaderChannelRegistry will be created. -2026-05-29 19:17:18.104 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'fineract-org.apache.fineract.infrastructure.core.config.FineractProperties' of type [org.apache.fineract.infrastructure.core.config.FineractProperties] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.117 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'cacheConfig' of type [org.apache.fineract.infrastructure.core.config.cache.CacheConfig$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.335 - INFO 7760  [ no-tenant] --- [ main] org.reflections.Reflections  : Reflections took 7 ms to scan 1 urls, producing 0 keys and 0 values -2026-05-29 19:17:18.339 - WARN 7760  [ no-tenant] --- [ main] o.a.f.i.core.config.cache.CacheConfig  : The following cache configurations are defined but cache does not exists: [userTFAccessToken] -2026-05-29 19:17:18.343 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'ehCacheManager' of type [org.springframework.cache.jcache.JCacheCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.354 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'defaultCacheManager' of type [org.apache.fineract.infrastructure.core.config.cache.TransactionBoundCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.360 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'runtimeDelegatingCacheManager' of type [org.apache.fineract.infrastructure.cache.service.RuntimeDelegatingCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.364 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'platformCacheConfiguration' of type [org.apache.fineract.infrastructure.cache.PlatformCacheConfiguration$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.407 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'samplingConfiguration' of type [org.apache.fineract.infrastructure.core.diagnostics.performance.sampling.core.SamplingConfiguration] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:18.415 - WARN 7760  [ no-tenant] --- [ main] trationDelegate$BeanPostProcessorChecker : Bean 'samplingServiceFactory' of type [org.apache.fineract.infrastructure.core.diagnostics.performance.sampling.core.SamplingServiceFactory] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a currently created BeanPostProcessor [samplingBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be post-processed, declare it with ROLE_INFRASTRUCTURE. -2026-05-29 19:17:19.255 - INFO 7760  [ no-tenant] --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8443 (https) -2026-05-29 19:17:19.278 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardService  : Starting service [Tomcat] -2026-05-29 19:17:19.279 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/10.1.49] -2026-05-29 19:17:19.328 - INFO 7760  [ no-tenant] --- [ main] o.a.c.c.C.[.[.[/fineract-provider]  : Initializing Spring embedded WebApplicationContext -2026-05-29 19:17:19.329 - INFO 7760  [ no-tenant] --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 13673 ms -2026-05-29 19:17:19.577 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Starting... -2026-05-29 19:17:19.860 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.pool.HikariPool  : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@5917b984 -2026-05-29 19:17:19.861 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Start completed. -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrading tenant store DB at localhost:5432 -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.username: postgres -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.password: **** -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.parameters: -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.timezone: Asia/Kolkata -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.description: Default Demo Tenant -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.identifier: default -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - fineract.tenant.name: fineract_default -2026-05-29 19:17:20.050 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : - liquibase.analytics.enabled: false -2026-05-29 19:17:25.487 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Tenant store upgrade finished -2026-05-29 19:17:25.487 - INFO 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrading all tenants -2026-05-29 19:17:25.501 - INFO 7760  [ default] --- [lTaskExecutor-1] a.f.i.c.s.m.TenantDatabaseUpgradeService : Upgrade for tenant default has started -2026-05-29 19:17:25.842 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Starting... -2026-05-29 19:17:25.946 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.pool.HikariPool  : HikariPool-2 - Added connection org.postgresql.jdbc.PgConnection@625805c1 -2026-05-29 19:17:25.946 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Start completed. -2026-05-29 19:17:31.700 -ERROR 7760  [ default] --- [lTaskExecutor-1] liquibase.changelog  : ChangeSet db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract encountered an exception. - -liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:520) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:85) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:188) - at liquibase.executor.AbstractExecutor.execute(AbstractExecutor.java:148) - at liquibase.database.AbstractJdbcDatabase.executeStatements(AbstractJdbcDatabase.java:1198) - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:816) - at liquibase.changelog.visitor.UpdateVisitor.executeAcceptedChange(UpdateVisitor.java:127) - at liquibase.changelog.visitor.UpdateVisitor.visit(UpdateVisitor.java:71) - at liquibase.changelog.ChangeLogIterator.lambda$run$0(ChangeLogIterator.java:133) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.changelog.ChangeLogIterator.lambda$run$1(ChangeLogIterator.java:122) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Scope.child(Scope.java:282) - at liquibase.Scope.child(Scope.java:286) - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:91) - at liquibase.command.core.AbstractUpdateCommandStep.lambda$run$0(AbstractUpdateCommandStep.java:114) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.command.core.AbstractUpdateCommandStep.run(AbstractUpdateCommandStep.java:112) - at liquibase.command.core.UpdateCommandStep.run(UpdateCommandStep.java:100) - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:263) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:201) - at liquibase.command.CommandScope.execute(CommandScope.java:251) - at liquibase.Liquibase.lambda$update$0(Liquibase.java:216) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Liquibase.runInScope(Liquibase.java:1366) - at liquibase.Liquibase.update(Liquibase.java:205) - at liquibase.Liquibase.update(Liquibase.java:188) - at liquibase.integration.spring.SpringLiquibase.performUpdate(SpringLiquibase.java:327) - at liquibase.integration.spring.SpringLiquibase.lambda$afterPropertiesSet$0(SpringLiquibase.java:279) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:272) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:205) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.lambda$upgradeIndividualTenants$1(TenantDatabaseUpgradeService.java:146) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) - at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - at java.base/java.lang.Thread.run(Thread.java:1583) -Caused by: org.postgresql.util.PSQLException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). - at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2736) - at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2421) - at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:372) - at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:525) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:435) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:357) - at org.postgresql.jdbc.PgStatement.executeCachedSql(PgStatement.java:342) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:318) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:313) - at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:95) - at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java) - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:512) - ... 52 common frames omitted - -2026-05-29 19:17:31.748 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Shutdown initiated... -2026-05-29 19:17:31.750 - INFO 7760  [ default] --- [lTaskExecutor-1] com.zaxxer.hikari.HikariDataSource  : HikariPool-2 - Shutdown completed. -2026-05-29 19:17:31.752 -ERROR 7760  [ no-tenant] --- [ main] a.f.i.c.s.m.TenantDatabaseUpgradeService : Exception: - -java.util.concurrent.ExecutionException: java.lang.RuntimeException: Exception while upgrading tenant default - at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122) - at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenants(TenantDatabaseUpgradeService.java:155) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.afterPropertiesSet(TenantDatabaseUpgradeService.java:93) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1873) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1822) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:315) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:691) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:513) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyPropertyValues(AbstractAutowireCapableBeanFactory.java:1725) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1474) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:606) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1698) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansWithAnnotation(DefaultListableBeanFactory.java:796) - at org.springframework.context.support.AbstractApplicationContext.getBeansWithAnnotation(AbstractApplicationContext.java:1440) - at org.apache.fineract.infrastructure.core.jersey.JerseyConfig.setup(JerseyConfig.java:58) - at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) - at java.base/java.lang.reflect.Method.invoke(Method.java:580) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMethod.invoke(InitDestroyAnnotationBeanPostProcessor.java:457) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:401) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:219) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:429) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1818) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:207) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:230) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:221) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addServletContextInitializerBeans(ServletContextInitializerBeans.java:106) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.(ServletContextInitializerBeans.java:95) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.getServletContextInitializerBeans(ServletWebServerApplicationContext.java:271) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.selfInitialize(ServletWebServerApplicationContext.java:245) - at org.springframework.boot.web.embedded.tomcat.TomcatStarter.onStartup(TomcatStarter.java:52) - at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4452) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:770) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardService.startInternal(StandardService.java:410) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:868) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.startup.Tomcat.start(Tomcat.java:436) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:128) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.(TomcatWebServer.java:107) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:517) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:219) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:193) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) - at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) - at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) - at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) - at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) - at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:149) - at org.apache.fineract.ServerApplication.main(ServerApplication.java:57) -Caused by: java.lang.RuntimeException: Exception while upgrading tenant default - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:208) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.lambda$upgradeIndividualTenants$1(TenantDatabaseUpgradeService.java:146) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) - at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - at java.base/java.lang.Thread.run(Thread.java:1583) -Caused by: liquibase.exception.LiquibaseException: liquibase.exception.CommandExecutionException: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:289) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenant(TenantDatabaseUpgradeService.java:205) - ... 5 common frames omitted -Caused by: liquibase.exception.CommandExecutionException: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:310) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:201) - at liquibase.command.CommandScope.execute(CommandScope.java:251) - at liquibase.Liquibase.lambda$update$0(Liquibase.java:216) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Liquibase.runInScope(Liquibase.java:1366) - at liquibase.Liquibase.update(Liquibase.java:205) - at liquibase.Liquibase.update(Liquibase.java:188) - at liquibase.integration.spring.SpringLiquibase.performUpdate(SpringLiquibase.java:327) - at liquibase.integration.spring.SpringLiquibase.lambda$afterPropertiesSet$0(SpringLiquibase.java:279) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.integration.spring.SpringLiquibase.afterPropertiesSet(SpringLiquibase.java:272) - ... 6 common frames omitted -Caused by: liquibase.exception.LiquibaseException: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:155) - at liquibase.command.core.AbstractUpdateCommandStep.lambda$run$0(AbstractUpdateCommandStep.java:114) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.command.core.AbstractUpdateCommandStep.run(AbstractUpdateCommandStep.java:112) - at liquibase.command.core.UpdateCommandStep.run(UpdateCommandStep.java:100) - at liquibase.command.CommandScope.lambda$execute$6(CommandScope.java:263) - ... 24 common frames omitted -Caused by: liquibase.exception.MigrationFailedException: Migration failed for changeset db/changelog/tenant/parts/0309_phase3_jobs_and_recall_permission.xml::0309_imps_deemed_reconcile_job::fineract: - Reason: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:873) - at liquibase.changelog.visitor.UpdateVisitor.executeAcceptedChange(UpdateVisitor.java:127) - at liquibase.changelog.visitor.UpdateVisitor.visit(UpdateVisitor.java:71) - at liquibase.changelog.ChangeLogIterator.lambda$run$0(ChangeLogIterator.java:133) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.changelog.ChangeLogIterator.lambda$run$1(ChangeLogIterator.java:122) - at liquibase.Scope.lambda$child$0(Scope.java:216) - at liquibase.Scope.child(Scope.java:225) - at liquibase.Scope.child(Scope.java:215) - at liquibase.Scope.child(Scope.java:194) - at liquibase.Scope.child(Scope.java:282) - at liquibase.Scope.child(Scope.java:286) - at liquibase.changelog.ChangeLogIterator.run(ChangeLogIterator.java:91) - ... 32 common frames omitted -Caused by: liquibase.exception.DatabaseException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). [Failed SQL: (0) INSERT INTO public.job (name, display_name, cron_expression, create_time, task_priority, group_name, previous_run_start_time, job_key, initializing_errorlog, is_active, currently_running, updates_allowed, scheduler_group, is_misfired, node_id, is_mismatched_job) VALUES ('IMPS DEEMED Reconciliation', 'IMPS DEEMED Reconciliation', '0/30 * * * * ?', NOW(), 5, NULL, NULL, 'IMPS DEEMED ReconciliationDetail1 _ DEFAULT', NULL, FALSE, FALSE, TRUE, 0, FALSE, 1, TRUE)] - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:520) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:85) - at liquibase.executor.jvm.JdbcExecutor.execute(JdbcExecutor.java:188) - at liquibase.executor.AbstractExecutor.execute(AbstractExecutor.java:148) - at liquibase.database.AbstractJdbcDatabase.executeStatements(AbstractJdbcDatabase.java:1198) - at liquibase.changelog.ChangeSet.execute(ChangeSet.java:816) - ... 47 common frames omitted -Caused by: org.postgresql.util.PSQLException: ERROR: null value in column "short_name" of relation "job" violates not-null constraint - Detail: Failing row contains (45, IMPS DEEMED Reconciliation, IMPS DEEMED Reconciliation, 0/30 * * * * ?, 2026-05-29 19:17:31.688453, 5, null, null, null, IMPS DEEMED ReconciliationDetail1 _ DEFAULT, null, f, f, t, 0, f, 1, t, null). - at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2736) - at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2421) - at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:372) - at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:525) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:435) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:357) - at org.postgresql.jdbc.PgStatement.executeCachedSql(PgStatement.java:342) - at org.postgresql.jdbc.PgStatement.executeWithFlags(PgStatement.java:318) - at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:313) - at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:95) - at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java) - at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:512) - ... 52 common frames omitted - -2026-05-29 19:17:31.761 -ERROR 7760  [ no-tenant] --- [ main] o.s.b.web.embedded.tomcat.TomcatStarter  : Error starting Tomcat context. Exception: org.springframework.beans.factory.UnsatisfiedDependencyException. Message: Error creating bean with name 'org.springframework.boot.autoconfigure.jersey.JerseyAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'jerseyConfig': Invocation of init method failed -2026-05-29 19:17:31.812 - INFO 7760  [ no-tenant] --- [ main] o.apache.catalina.core.StandardService  : Stopping service [Tomcat] -2026-05-29 19:17:31.817 - WARN 7760  [ no-tenant] --- [ main] o.a.c.loader.WebappClassLoaderBase  : The web application [fineract-provider] appears to have started a thread named [HikariPool-1:housekeeper] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread: - java.base/jdk.internal.misc.Unsafe.park(Native Method) - java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269) - java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1797) - java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1182) - java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:899) - java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1070) - java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130) - java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) - java.base/java.lang.Thread.run(Thread.java:1583) -2026-05-29 19:17:31.820 - WARN 7760  [ no-tenant] --- [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.context.ApplicationContextException: Unable to start web server -2026-05-29 19:17:31.820 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown initiated... -2026-05-29 19:17:31.822 - INFO 7760  [ no-tenant] --- [ main] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown completed. -2026-05-29 19:17:31.847 - INFO 7760  [ no-tenant] --- [ main] .s.b.a.l.ConditionEvaluationReportLogger : - -Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled. -2026-05-29 19:17:31.875 -ERROR 7760  [ no-tenant] --- [ main] o.s.boot.SpringApplication  : Application run failed - -org.springframework.context.ApplicationContextException: Unable to start web server - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:170) - at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:621) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) - at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) - at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) - at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) - at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:149) - at org.apache.fineract.ServerApplication.main(ServerApplication.java:57) -Caused by: org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:147) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.(TomcatWebServer.java:107) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:517) - at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:219) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:193) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) - ... 7 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.jersey.JerseyAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'jerseyConfig': Invocation of init method failed - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:207) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:230) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:221) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addServletContextInitializerBeans(ServletContextInitializerBeans.java:106) - at org.springframework.boot.web.servlet.ServletContextInitializerBeans.(ServletContextInitializerBeans.java:95) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.getServletContextInitializerBeans(ServletWebServerApplicationContext.java:271) - at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.selfInitialize(ServletWebServerApplicationContext.java:245) - at org.springframework.boot.web.embedded.tomcat.TomcatStarter.onStartup(TomcatStarter.java:52) - at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4452) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:770) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1201) - at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1191) - at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) - at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) - at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145) - at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:747) - at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardService.startInternal(StandardService.java:410) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:868) - at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) - at org.apache.catalina.startup.Tomcat.start(Tomcat.java:436) - at org.springframework.boot.web.embedded.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:128) - ... 12 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jerseyConfig': Invocation of init method failed - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:222) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization(AbstractAutowireCapableBeanFactory.java:429) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1818) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 61 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'journalEntriesApiResource' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\accounting\journalentry\api\JournalEntriesApiResource.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'springSecurityPlatformSecurityContext' defined in URL [jar:file:/C:/Users/adity/Music/CBS/fineract/fineract-security/build/libs/fineract-security-1.15.0-SNAPSHOT.jar!/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeansWithAnnotation(DefaultListableBeanFactory.java:796) - at org.springframework.context.support.AbstractApplicationContext.getBeansWithAnnotation(AbstractApplicationContext.java:1440) - at org.apache.fineract.infrastructure.core.jersey.JerseyConfig.setup(JerseyConfig.java:58) - at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) - at java.base/java.lang.reflect.Method.invoke(Method.java:580) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMethod.invoke(InitDestroyAnnotationBeanPostProcessor.java:457) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:401) - at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:219) - ... 74 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'springSecurityPlatformSecurityContext' defined in URL [jar:file:/C:/Users/adity/Music/CBS/fineract/fineract-security/build/libs/fineract-security-1.15.0-SNAPSHOT.jar!/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 91 common frames omitted -Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'configurationDomainServiceJpa' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\configuration\domain\ConfigurationDomainServiceJpa.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) - at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1760) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 105 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'permissionRepository' defined in org.apache.fineract.useradministration.domain.PermissionRepository defined in @EnableJpaRepositories declared on JPAConfig: Cannot resolve reference to bean 'jpaSharedEM_entityManagerFactory' while setting bean property 'entityManager' - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyPropertyValues(AbstractAutowireCapableBeanFactory.java:1725) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1474) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:606) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1698) - at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1643) - at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) - at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) - ... 119 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaSharedEM_entityManagerFactory': Cannot resolve reference to bean 'entityManagerFactory' while setting constructor argument - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:135) - at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:691) - at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:513) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - ... 132 common frames omitted -Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'tenantDatabaseUpgradeService' defined in file [C:\Users\adity\Music\CBS\fineract\fineract-provider\build\classes\java\main\org\apache\fineract\infrastructure\core\service\migration\TenantDatabaseUpgradeService.class]: Tenant upgrades had exceptions - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1826) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:607) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) - at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) - at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:315) - at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) - at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:365) - ... 144 common frames omitted -Caused by: java.lang.RuntimeException: Tenant upgrades had exceptions - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.upgradeIndividualTenants(TenantDatabaseUpgradeService.java:167) - at org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.afterPropertiesSet(TenantDatabaseUpgradeService.java:93) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1873) - at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1822) - ... 153 common frames omitted - - -> Task :fineract-provider:bootRun FAILED - -[Incubating] Problems report is available at: file:///C:/Users/adity/Music/CBS/fineract/build/reports/problems/problems-report.html - -FAILURE: Build failed with an exception. - -* What went wrong: -Execution failed for task ':fineract-provider:bootRun'. -> Process 'command 'C:\Program Files\Eclipse Adoptium\jdk-21.0.9.10-hotspot\bin\java.exe'' finished with non-zero exit value 1 - -* Try: -> Run with --stacktrace option to get the stack trace. -> Run with --info or --debug option to get more log output. -> Get more help at https://help.gradle.org. - -Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0. - -You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins. - -For more on this, please refer to https://docs.gradle.org/8.14.3/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation. - -BUILD FAILED in 1m 30s -83 actionable tasks: 5 executed, 78 up-to-date -Resource usage collection shutdown not awaited diff --git a/fineract-provider/logs/bootrun_output.log b/fineract-provider/logs/bootrun_output.log deleted file mode 100644 index a6cdc98da..000000000 --- a/fineract-provider/logs/bootrun_output.log +++ /dev/null @@ -1,18 +0,0 @@ -> Task :buildSrc:extractPluginRequests UP-TO-DATE -> Task :buildSrc:generatePluginAdapters UP-TO-DATE -> Task :buildSrc:compileJava UP-TO-DATE -> Task :buildSrc:compileGroovy UP-TO-DATE -> Task :buildSrc:compileGroovyPlugins UP-TO-DATE -> Task :buildSrc:pluginDescriptors UP-TO-DATE -> Task :buildSrc:processResources UP-TO-DATE -> Task :buildSrc:classes UP-TO-DATE -> Task :buildSrc:jar UP-TO-DATE - -> Configure project : -matching ref: COMMIT - d7821c7824067a96d31c4c031ea538fc5e4107fc - ref configuration: COMMIT - pattern: null - version: ${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT - describeTagPattern: .*(\d+\.\d+\.\d+).* - describeTagFirstParent: false - -project version: 0.1.0-SNAPSHOT diff --git a/fineract-provider/logs/devrun.out b/fineract-provider/logs/devrun.out deleted file mode 100644 index f259f210d..000000000 Binary files a/fineract-provider/logs/devrun.out and /dev/null differ diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 930c06782..28b7389fd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -101,6 +101,13 @@ public class CashierDepositRequestApiResource { final String bankName = fromJsonHelper.extractStringNamed("bankName", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests. + // Persisted so the Sponsor DD issuance screen can read the breakdown back instead of + // retyping face value/charges once this request is approved and selected as cash evidence. + final BigDecimal ddFaceValue = parseBigDecimal(fromJsonHelper.extractStringNamed("ddFaceValue", root)); + final BigDecimal ddCharges = parseBigDecimal(fromJsonHelper.extractStringNamed("ddCharges", root)); + final BigDecimal ddGstPercentage = parseBigDecimal(fromJsonHelper.extractStringNamed("ddGstPercentage", root)); + final BigDecimal ddGstAmount = parseBigDecimal(fromJsonHelper.extractStringNamed("ddGstAmount", root)); if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); @@ -115,6 +122,7 @@ public class CashierDepositRequestApiResource { throw apiError(400, "Amount must be greater than zero"); } validateDenominationTotal(denominationData, amount); + validateDdLimit(ddFaceValue); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -123,8 +131,9 @@ public class CashierDepositRequestApiResource { (account_no, account_type, savings_account_id, gl_account_id, contra_gl_id, office_id, currency_code, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, - status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -135,7 +144,8 @@ public class CashierDepositRequestApiResource { txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - denominationData, activityLog, Timestamp.from(Instant.now()) + denominationData, activityLog, Timestamp.from(Instant.now()), + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount ); // Globally unique voucher number from the central m_voucher register @@ -189,7 +199,8 @@ public class CashierDepositRequestApiResource { + " amount, payment_type_id, payment_type_name, description, cheque_no, bank_name," + " txn_date, status, cashier_id, cashier_name, cashier_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," - + " denomination_data, activity_log, submitted_at, actioned_at" + + " denomination_data, activity_log, submitted_at, actioned_at," + + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount" + " FROM m_cashier_deposit_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -242,7 +253,8 @@ public class CashierDepositRequestApiResource { SELECT id, voucher_no, account_no, account_type, client_name, amount, payment_type_name, description, txn_date, status, cashier_name, manager_name, manager_username, rejection_reason, txn_id, - submitted_at, actioned_at + submitted_at, actioned_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount FROM m_cashier_deposit_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -424,6 +436,7 @@ public class CashierDepositRequestApiResource { final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); res.addProperty("status", "APPROVED"); + res.addProperty("voucherNo", Optional.ofNullable(record.get("voucher_no")).map(Object::toString).orElse("")); if (txnId != null) res.addProperty("txnId", txnId); return gson.toJson(res); } @@ -532,7 +545,8 @@ public class CashierDepositRequestApiResource { amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, - denomination_data, activity_log, submitted_at, actioned_at + denomination_data, activity_log, submitted_at, actioned_at, + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount FROM m_cashier_deposit_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -869,6 +883,26 @@ public class CashierDepositRequestApiResource { } } + /** + * DD + Cash + GL deposit requests carry a Face Value (ddFaceValue) - reject the cash intake + * itself, before the cashier even accepts the money, if it exceeds the admin-configured DD + * limit (m_sponsor_dd_gl_mapping.dd_limit_amount). The same limit is enforced again later at + * actual DD issuance (sponsordd module); this is a first line of defense so an over-limit + * amount never even gets accepted as cash. ddFaceValue is null for ordinary (non-DD) deposit + * requests, which this check skips entirely. Null/zero-or-negative limit means unconfigured. + */ + private void validateDdLimit(final BigDecimal ddFaceValue) { + if (ddFaceValue == null) return; + final List> rows = jdbcTemplate.queryForList( + "SELECT dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1"); + if (rows.isEmpty()) return; + final BigDecimal ddLimitAmount = (BigDecimal) rows.get(0).get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) return; + if (ddFaceValue.compareTo(ddLimitAmount) > 0) { + throw apiError(400, "DD Face Value ₹" + ddFaceValue + " exceeds the configured DD limit of ₹" + ddLimitAmount + "."); + } + } + /** * Checker authorization: only manager-level users may approve or reject. * Allowed: super users (ALL_FUNCTIONS), or any role whose name contains 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/customersearch/api/CustomerSearchApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/api/CustomerSearchApiResource.java new file mode 100644 index 000000000..9592300e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/api/CustomerSearchApiResource.java @@ -0,0 +1,84 @@ +/** + * 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.customersearch.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; +import org.apache.fineract.portfolio.customersearch.service.CustomerSearchReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/customer-search") +@Component +@Tag(name = "Customer Search", description = "Multi-field customer lookup by name, account number (savings/current/FD/RD), Aadhaar, PAN, and/or address.") +@RequiredArgsConstructor +public class CustomerSearchApiResource { + + private final CustomerSearchReadPlatformService readPlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).") + public Collection search(@QueryParam("name") final String name, + @QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber, + @QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address, + @QueryParam("accountTypes") final String accountTypes) { + final List accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of() + : Arrays.asList(accountTypes.split(",")); + return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList)); + } + + /** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException e) { + final JsonObject body = new JsonObject(); + body.addProperty("message", e.getMessage()); + body.addProperty("defaultUserMessage", e.getMessage()); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", e.getMessage()); + errors.add(err); + body.add("errors", errors); + throw new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java new file mode 100644 index 000000000..e1c318ab5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/data/CustomerSearchResultData.java @@ -0,0 +1,47 @@ +/** + * 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.customersearch.data; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One matching (client, account) combination from a multi-field customer search. A client with + * several accounts appears once per matching account; a client with no accounts at all still + * appears once, with accountNo/accountType left null. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CustomerSearchResultData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long clientId; + private String displayName; + private String mobileNo; + private String officeName; + private String accountNo; + /** SAVINGS, CURRENT, FIXED_DEPOSIT, or RECURRING_DEPOSIT. */ + private String accountType; + private String panNumber; + private String aadhaarNumber; + private String address; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java new file mode 100644 index 000000000..7a4eaab66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformService.java @@ -0,0 +1,38 @@ +/** + * 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.customersearch.service; + +import java.util.Collection; +import java.util.List; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; + +public interface CustomerSearchReadPlatformService { + + /** + * Multi-field AND search - only non-blank parameters are applied. At least one must be + * supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/ + * panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if + * non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/ + * RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded + * entirely when this filter is active (unlike the unfiltered case, where a client with no + * account at all still appears once for informational purposes). + */ + Collection search(String name, String accountNumber, String aadhaarNumber, String panNumber, + String address, List accountTypes); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java new file mode 100644 index 000000000..5ed94011b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java @@ -0,0 +1,159 @@ +/** + * 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.customersearch.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.customersearch.data.CustomerSearchResultData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final Set VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT"); + + private static final String BASE_SELECT = """ + SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name, + acct.account_no, acct.account_type, + pan.document_key AS pan_number, aad.document_key AS aadhaar_number, + addr.address_line_1, addr.address_line_2, addr.town_village, addr.city, + addr.county_district, addr.postal_code + FROM m_client c + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = ( + SELECT id FROM m_code_value WHERE code_value = 'PAN Card' + AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType')) + LEFT JOIN m_client_identifier aad ON aad.client_id = c.id AND aad.document_type_id = ( + SELECT id FROM m_code_value WHERE code_value = 'Aadhaar Card' + AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType')) + LEFT JOIN m_address addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT client_id, account_no, + CASE deposit_type_enum WHEN 100 THEN 'SAVINGS' WHEN 200 THEN 'FIXED_DEPOSIT' + WHEN 300 THEN 'RECURRING_DEPOSIT' ELSE 'SAVINGS' END AS account_type + FROM m_savings_account + UNION ALL + SELECT client_id, account_no, 'CURRENT' AS account_type FROM m_current_account + ) acct ON acct.client_id = c.id + """; + + @Override + public Collection search(final String name, final String accountNumber, final String aadhaarNumber, + final String panNumber, final String address, final List accountTypes) { + final boolean hasName = name != null && !name.isBlank(); + final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank(); + final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank(); + final boolean hasPan = panNumber != null && !panNumber.isBlank(); + final boolean hasAddress = address != null && !address.isBlank(); + final List effectiveAccountTypes = accountTypes == null ? List.of() + : accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList(); + + if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) { + throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address."); + } + for (final String type : effectiveAccountTypes) { + if (!VALID_ACCOUNT_TYPES.contains(type)) { + throw new IllegalArgumentException( + "Unknown account type: " + type + ". Valid values: SAVINGS, CURRENT, FIXED_DEPOSIT, RECURRING_DEPOSIT."); + } + } + + final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 "); + final List params = new ArrayList<>(); + + if (hasName) { + sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) "); + params.add("%" + name.trim() + "%"); + } + if (hasAccountNumber) { + sql.append(" AND acct.account_no = ? "); + params.add(accountNumber.trim()); + } + if (hasAadhaar) { + sql.append(" AND aad.document_key = ? "); + params.add(aadhaarNumber.trim()); + } + if (hasPan) { + sql.append(" AND UPPER(pan.document_key) = UPPER(?) "); + params.add(panNumber.trim()); + } + if (hasAddress) { + sql.append(" AND (LOWER(addr.address_line_1) LIKE LOWER(?) OR LOWER(addr.address_line_2) LIKE LOWER(?) " + + "OR LOWER(addr.town_village) LIKE LOWER(?) OR LOWER(addr.city) LIKE LOWER(?) " + + "OR LOWER(addr.county_district) LIKE LOWER(?) OR LOWER(addr.postal_code) LIKE LOWER(?)) "); + final String likeAddress = "%" + address.trim() + "%"; + for (int i = 0; i < 6; i++) { + params.add(likeAddress); + } + } + if (!effectiveAccountTypes.isEmpty()) { + // Restricting to specific account types excludes both non-matching-type accounts AND + // clients with no account at all (acct.account_type would be NULL for those, which can + // never equal one of the listed types) - that's the point of the filter. + final String placeholders = String.join(",", effectiveAccountTypes.stream().map(t -> "?").toList()); + sql.append(" AND acct.account_type IN (").append(placeholders).append(") "); + params.addAll(effectiveAccountTypes); + } + sql.append(" ORDER BY c.display_name ASC LIMIT 100"); + + return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray()); + } + + private static final class CustomerSearchResultMapper implements RowMapper { + @Override + public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long clientId = rs.getLong("client_id"); + final String displayName = rs.getString("display_name"); + final String mobileNo = rs.getString("mobile_no"); + final String officeName = rs.getString("office_name"); + final String accountNo = rs.getString("account_no"); + final String accountType = rs.getString("account_type"); + final String panNumber = rs.getString("pan_number"); + final String aadhaarNumber = rs.getString("aadhaar_number"); + + final List addressParts = new ArrayList<>(); + addPart(addressParts, rs.getString("address_line_1")); + addPart(addressParts, rs.getString("address_line_2")); + addPart(addressParts, rs.getString("town_village")); + addPart(addressParts, rs.getString("city")); + addPart(addressParts, rs.getString("county_district")); + addPart(addressParts, rs.getString("postal_code")); + final String address = addressParts.isEmpty() ? null : String.join(", ", addressParts); + + return new CustomerSearchResultData(clientId, displayName, mobileNo, officeName, accountNo, accountType, panNumber, + aadhaarNumber, address); + } + + private void addPart(final List parts, final String value) { + if (value != null && !value.isBlank()) { + parts.add(value); + } + } + } +} 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 de44fac30..bf6d014e8 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, " @@ -607,8 +635,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")); @@ -619,7 +657,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 873bf3962..41c270644 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); @@ -718,6 +705,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/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java new file mode 100644 index 000000000..4e482007d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -0,0 +1,138 @@ +/** + * 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.sponsordd.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.function.Supplier; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +@Path("/v1/sponsorbanks") +@Component +@Tag(name = "Sponsor Bank", description = "Endpoints for managing sponsor banks.") +@RequiredArgsConstructor +public class SponsorBankApiResource { + + private final SponsorBankRepository sponsorBankRepository; + private final PlatformSecurityContext context; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException | IllegalStateException e) { + final JsonObject body = new JsonObject(); + body.addProperty("message", e.getMessage()); + body.addProperty("defaultUserMessage", e.getMessage()); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", e.getMessage()); + errors.add(err); + body.add("errors", errors); + throw new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + } + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all Sponsor Banks", description = "Returns a list of all registered Sponsor Banks.") + public Collection retrieveAllSponsorBanks() { + return this.sponsorBankRepository.findAll().stream() + .map(sb -> new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), + sb.getSwiftCode())) + .toList(); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.") + public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + return safely(() -> { + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorBank sponsorBank = new SponsorBank( + request.getName(), + request.getDescription(), + request.getIfscCode() != null ? request.getIfscCode().trim().toUpperCase() : null, + request.getBankCode(), + request.getSwiftCode() != null ? request.getSwiftCode().trim().toUpperCase() : null, + currentUser, + LocalDateTime.now() + ); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + }); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SponsorBankData { + private Long id; + private String name; + private String description; + private String ifscCode; + private String bankCode; + private String swiftCode; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SponsorBankRequest { + private String name; + private String description; + private String ifscCode; + private String bankCode; + private String swiftCode; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java new file mode 100644 index 000000000..a2d1dcd0c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -0,0 +1,633 @@ +/** + * 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.sponsordd.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/sponsordd") +@Component +@Tag(name = "Sponsor Demand Draft", description = "Endpoints for managing sponsor bank demand draft inventory, leaflet lifecycles, and clearing reconciliation.") +@RequiredArgsConstructor +public class SponsorDDApiResource { + + private final SponsorDDReadPlatformService readPlatformService; + private final SponsorDDWritePlatformService writePlatformService; + private final SponsorDDReconciliationService reconciliationService; + private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; + private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; + private final PlatformSecurityContext context; + private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * Business-rule validation in this module is expressed with plain IllegalArgumentException / + * IllegalStateException (see SponsorDDWritePlatformServiceImpl / SponsorDDIssueRequestWritePlatformServiceImpl). + * Those aren't mapped by Fineract's global exception handler, so left uncaught they surface to the + * frontend as an opaque 500. This wraps every write endpoint so validation failures come back as a + * proper 400 with the actual message. + */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException | IllegalStateException e) { + throw apiError(400, e.getMessage()); + } + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + + @GET + @Path("inventory") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all DD Inventories", description = "Returns a list of all registered Sponsor DD Inventory blocks.") + public Collection retrieveAllInventories() { + return this.readPlatformService.retrieveAllInventories(); + } + + @GET + @Path("inventory/{inventoryId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD Inventory block details", description = "Returns detail metadata for a specific DD inventory block.") + public SponsorDDInventoryData retrieveInventory(@PathParam("inventoryId") final Long inventoryId) { + return this.readPlatformService.retrieveInventory(inventoryId); + } + + @POST + @Path("inventory") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Register new DD Inventory block", description = "Creates a new DD inventory series and auto-generates associated available leaflets.") + public CommandProcessingResult registerInventory(final RegisterInventoryRequest request) { + final LocalDate receivedDate = LocalDate.parse(request.getReceivedDate(), this.dateFormatter); + return safely(() -> this.writePlatformService.registerInventory( + request.getSponsorBankId(), + request.getBranchId(), + request.getSeriesStart(), + request.getSeriesEnd(), + receivedDate + )); + } + + @GET + @Path("inventory/{inventoryId}/leaflets") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all leaflets in an inventory", description = "Returns status registry list of all leaflets under a specific inventory batch.") + public Collection retrieveAllLeafletsByInventory(@PathParam("inventoryId") final Long inventoryId) { + return this.readPlatformService.retrieveAllLeafletsByInventory(inventoryId); + } + + @GET + @Path("leaflets/{leafletId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve specific leaflet details", description = "Returns detailed metadata and audit properties for a single leaflet.") + public SponsorDDLeafletData retrieveLeaflet(@PathParam("leafletId") final Long leafletId) { + return this.readPlatformService.retrieveLeaflet(leafletId); + } + + @GET + @Path("leaflets/by-number/{ddNumber}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Find leaflet by DD Number", description = "Finds and returns a leaflet's data structure searching by its pre-printed DD Number.") + public SponsorDDLeafletData retrieveLeafletByDdNumber(@PathParam("ddNumber") final String ddNumber) { + return this.readPlatformService.retrieveLeafletByDdNumber(ddNumber); + } + + @POST + @Path("leaflets/{leafletId}/issue") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Issue a demand draft", description = "Transitions an AVAILABLE leaflet to ISSUED and links it to customer debit transactions.") + public CommandProcessingResult issueDemandDraft(@PathParam("leafletId") final Long leafletId, final IssueDDRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + return safely(() -> this.writePlatformService.issueDemandDraft( + leafletId, + request.getClientId(), + null, + null, + null, + request.getBeneficiaryName(), + request.getFaceValue(), + request.getIssuingBranchId(), + issuedDate, + request.getSavingsTransactionId(), + request.getCurrentTransactionId(), + request.getJournalEntryId(), + null, + null + )); + } + + @POST + @Path("leaflets/{leafletId}/cancel") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Cancel a demand draft", description = "Transitions an ISSUED leaflet to CANCELLED and maps customer refund details.") + public CommandProcessingResult cancelDemandDraft(@PathParam("leafletId") final Long leafletId, final CancelDDRequest request) { + return safely(() -> this.writePlatformService.cancelDemandDraft( + leafletId, + request.getRemarks(), + request.getRefundSavingsTransactionId(), + request.getRefundCurrentTransactionId() + )); + } + + @POST + @Path("leaflets/{leafletId}/stop") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Process Stop Payment instruction", description = "Transitions an ISSUED leaflet to STOPPED.") + public CommandProcessingResult stopPaymentDemandDraft(@PathParam("leafletId") final Long leafletId, final StopDDRequest request) { + return safely(() -> this.writePlatformService.stopPaymentDemandDraft(leafletId, request.getRemarks())); + } + + @GET + @Path("leaflets") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve leaflets with filter", description = "Returns status registry list of leaflets filtered by sponsor bank, status, and advice status.") + public Collection retrieveLeaflets( + @QueryParam("sponsorBankId") final Long sponsorBankId, + @QueryParam("status") final String status, + @QueryParam("adviceStatus") final String adviceStatus) { + return this.readPlatformService.retrieveLeaflets(sponsorBankId, status, adviceStatus); + } + + @POST + @Path("leaflets/send-advice") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Send advice to sponsor bank", description = "Marks all issued, unsent leaflets for a specific sponsor bank as sent.") + public CommandProcessingResult sendAdvice(final SendAdviceRequest request) { + return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId())); + } + + // ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ── + + @POST + @Path("leaflets/{leafletId}/issue-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a DD issuance request", description = "Clerk (maker) step: records a pending DD issuance request funded either by an account debit or a cheque. No money moves until a manager approves it.") + public CommandProcessingResult submitIssueRequest(@PathParam("leafletId") final Long leafletId, final IssueRequestRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + return safely(() -> this.issueRequestWritePlatformService.submitIssueRequest( + leafletId, + request.getClientId(), + request.getWalkinPurchaserName(), + request.getWalkinPurchaserAddress(), + request.getWalkinPurchaserAadhaar(), + request.getBeneficiaryName(), + request.getPayableAtBranch(), + request.getPayableAtCity(), + request.getFaceValue(), + request.getChargesAmount(), + request.getIssuingBranchId(), + issuedDate, + request.getPaymentMethod(), + request.getSavingsAccountId(), + request.getCurrentAccountId(), + request.getChequeNumber(), + request.getChequeBankName(), + request.getCashierDepositRequestId() + )); + } + + @POST + @Path("inventory/{inventoryId}/issue-request-batch") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit or cheque funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") + public SponsorDDBatchIssueResult submitIssueRequestBatch(@PathParam("inventoryId") final Long inventoryId, + final IssueRequestBatchRequest request) { + final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); + final List lines = request.getLines() == null ? List.of() + : request.getLines().stream() + .map(l -> new SponsorDDSlipLine(l.getBeneficiaryName(), l.getPayableAtBranch(), l.getPayableAtCity(), + l.getFaceValue(), l.getChargesAmount())) + .collect(java.util.stream.Collectors.toList()); + return safely(() -> this.issueRequestWritePlatformService.submitIssueRequestBatch( + inventoryId, + request.getClientId(), + request.getIssuingBranchId(), + issuedDate, + request.getPaymentMethod(), + request.getSavingsAccountId(), + request.getCurrentAccountId(), + request.getChequeNumber(), + request.getChequeBankName(), + lines + )); + } + + @POST + @Path("issue-requests/batch/{batchId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting for the whole slip's total, then issues every draft in it. Approves every line together, not one at a time.") + public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId) { + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId)); + } + + @POST + @Path("issue-requests/batch/{batchId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a multi-DD slip", description = "Manager/head-cashier (checker) step: rejects every line of a pending slip together. All leaflets remain AVAILABLE.") + public CommandProcessingResult rejectIssueRequestBatch(@PathParam("batchId") final Long batchId, final RejectIssueRequestRequest request) { + return safely(() -> this.issueRequestWritePlatformService.rejectIssueRequestBatch(batchId, request.getRejectionReason())); + } + + @GET + @Path("cashier-deposit-requests/available") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve available cashier deposit requests for CASH DD issuance", description = "Returns approved cashier Transaction Entry (AC.TP=GL) deposit requests credited to the DD Cash Suspense GL account, not yet linked to any DD issuance request.") + public Collection retrieveAvailableCashierDepositRequests() { + return this.readPlatformService.retrieveAvailableCashierDepositRequests(); + } + + @GET + @Path("issue-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD issuance requests", description = "Returns all DD issuance requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.") + public Collection retrieveAllIssueRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllIssueRequests(status); + } + + @GET + @Path("issue-requests/mine") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve my DD issuance requests", description = "Returns the DD issuance requests submitted by the current clerk.") + public Collection retrieveMyIssueRequests() { + return this.readPlatformService.retrieveMyIssueRequests(this.context.authenticatedUser().getId()); + } + + @GET + @Path("issue-requests/{issueRequestId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a DD issuance request", description = "Returns detail for a single DD issuance request.") + public SponsorDDIssueRequestData retrieveIssueRequest(@PathParam("issueRequestId") final Long issueRequestId) { + return this.readPlatformService.retrieveIssueRequest(issueRequestId); + } + + @POST + @Path("issue-requests/{issueRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD issuance request", description = "Manager/head-cashier (checker) step: performs the real GL posting (client account debit or cheque-clearing debit, credited to the Sponsor DD Payable GL account) and issues the demand draft.") + public CommandProcessingResult approveIssueRequest(@PathParam("issueRequestId") final Long issueRequestId) { + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequest(issueRequestId)); + } + + @POST + @Path("issue-requests/{issueRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD issuance request", description = "Manager/head-cashier (checker) step: rejects a pending DD issuance request. The leaflet remains AVAILABLE.") + public CommandProcessingResult rejectIssueRequest(@PathParam("issueRequestId") final Long issueRequestId, final RejectIssueRequestRequest request) { + return safely(() -> this.issueRequestWritePlatformService.rejectIssueRequest(issueRequestId, request.getRejectionReason())); + } + + // ── DD reprint requests (maker-checker) ───────────────────────────────── + + @POST + @Path("leaflets/{leafletId}/print") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record a print of an issued DD", description = "First print of a leaflet is always allowed. Any print after that consumes a previously-approved reprint request, or fails if none exists.") + public CommandProcessingResult recordPrint(@PathParam("leafletId") final Long leafletId) { + return safely(() -> this.reprintRequestWritePlatformService.recordPrint(leafletId)); + } + + @POST + @Path("leaflets/{leafletId}/reprint-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request permission to reprint a DD", description = "Clerk (maker) step: requests manager approval to print an already-printed leaflet again.") + public CommandProcessingResult requestReprint(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) { + return safely(() -> this.reprintRequestWritePlatformService.requestReprint(leafletId, request.getReason())); + } + + @GET + @Path("reprint-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD reprint requests", description = "Returns all DD reprint requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.") + public Collection retrieveAllReprintRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllReprintRequests(status); + } + + @POST + @Path("reprint-requests/{reprintRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD reprint request", description = "Manager/head-cashier (checker) step: allows one further print of the leaflet.") + public CommandProcessingResult approveReprintRequest(@PathParam("reprintRequestId") final Long reprintRequestId) { + return safely(() -> this.reprintRequestWritePlatformService.approveReprintRequest(reprintRequestId)); + } + + @POST + @Path("reprint-requests/{reprintRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD reprint request", description = "Manager/head-cashier (checker) step: rejects a pending DD reprint request.") + public CommandProcessingResult rejectReprintRequest(@PathParam("reprintRequestId") final Long reprintRequestId, final RejectIssueRequestRequest request) { + return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason())); + } + + // ── Sponsor DD GL account configuration ─────────────────────────────── + + @GET + @Path("gl-mapping") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Sponsor DD GL mappings", description = "Returns the configured GL accounts (DD Payable liability + cheque-clearing suspense) for each sponsor bank / payment type.") + public Collection retrieveAllGlMappings() { + return this.readPlatformService.retrieveAllGlMappings(); + } + + @POST + @Path("gl-mapping") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.") + public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) { + return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping( + request.getSponsorBankId(), + request.getDdPayableGlAccountId(), + request.getChequeClearingGlAccountId(), + request.getCashGlAccountId(), + request.getChargesIncomeGlAccountId(), + request.getCashSuspenseGlAccountId(), + request.getGstPercentage(), + request.getGstPayableGlAccountId(), + request.getDdLimitAmount())); + } + + @POST + @Path("reconcile/upload") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Upload and process clearing scroll", description = "Imports Sponsor Bank clearing scroll content, matching DD numbers and face values.") + public RecoBatchResponse uploadAndReconcile(final RecoUploadRequest request) { + return safely(() -> { + final SponsorDDRecoBatch batch = this.reconciliationService.uploadAndReconcile(request.getFileName(), request.getFileContent()); + return new RecoBatchResponse( + batch.getId(), + batch.getBatchReference(), + batch.getStatus(), + batch.getTotalRecords(), + batch.getSuccessCount(), + batch.getMismatchCount() + ); + }); + } + + @POST + @Path("reconcile/resolve/{recordId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Resolve matching exception", description = "Manually resolves a reconciliation mismatch by approving force-match matching.") + public CommandProcessingResult resolveMismatch(@PathParam("recordId") final Long recordId, final ResolveMismatchRequest request) { + return safely(() -> this.reconciliationService.resolveMismatch( + recordId, + request.getActionType(), + request.getResolvedSavingsTxnId(), + request.getResolvedCurrentTxnId() + )); + } + + @GET + @Path("reconcile/batches") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all reconciliation batches", description = "Returns a list of all clearing scrolls processed.") + public Collection retrieveAllReconciliationBatches() { + return this.readPlatformService.retrieveAllReconciliationBatches(); + } + + @GET + @Path("reconcile/batches/{batchId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve reconciliation batch detail", description = "Returns metadata for a specific reconciliation upload batch.") + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(@PathParam("batchId") final Long batchId) { + return this.readPlatformService.retrieveReconciliationBatch(batchId); + } + + @GET + @Path("reconcile/batches/{batchId}/records") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve records in a reconciliation batch", description = "Returns all matched and exception scroll records within a specific batch.") + public Collection retrieveReconciliationRecordsByBatch(@PathParam("batchId") final Long batchId) { + return this.readPlatformService.retrieveReconciliationRecordsByBatch(batchId); + } + + // Requests DTOs + @lombok.Data + public static class RegisterInventoryRequest { + private Long sponsorBankId; + private Long branchId; + private Long seriesStart; + private Long seriesEnd; + private String receivedDate; + } + + @lombok.Data + public static class IssueDDRequest { + private Long clientId; + private String beneficiaryName; + private BigDecimal faceValue; + private Long issuingBranchId; + private String issuedDate; + private Long savingsTransactionId; + private Long currentTransactionId; + private Long journalEntryId; + } + + @lombok.Data + public static class CancelDDRequest { + private String remarks; + private Long refundSavingsTransactionId; + private Long refundCurrentTransactionId; + } + + @lombok.Data + public static class StopDDRequest { + private String remarks; + } + + @lombok.Data + public static class RecoUploadRequest { + private String fileName; + private String fileContent; + } + + @lombok.Data + public static class ResolveMismatchRequest { + private String actionType; // FORCE_MATCH + private Long resolvedSavingsTxnId; + private Long resolvedCurrentTxnId; + } + + @lombok.Data + public static class IssueRequestRequest { + private Long clientId; + // Walk-in (non-client) purchaser details — required instead of clientId when paymentMethod is CASH. + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaar; // raw 12-digit Aadhaar — masked to last 4 digits before storage + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private BigDecimal chargesAmount; // DD issuance commission/charges, if any + private Long issuingBranchId; + private String issuedDate; + private String paymentMethod; // ACCOUNT_DEBIT, CHEQUE, or CASH + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + // CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request + // that received this cash, credited to the DD Cash Suspense GL account. + private Long cashierDepositRequestId; + } + + @lombok.Data + public static class IssueRequestBatchRequest { + private Long clientId; + private Long issuingBranchId; + private String issuedDate; + private String paymentMethod; // ACCOUNT_DEBIT or CHEQUE only — a slip does not support CASH + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + private List lines; + } + + @lombok.Data + public static class SlipLineRequest { + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private BigDecimal chargesAmount; + } + + @lombok.Data + public static class RejectIssueRequestRequest { + private String rejectionReason; + } + + @lombok.Data + public static class ReprintRequestRequest { + private String reason; + } + + @lombok.Data + public static class GlMappingRequest { + private Long sponsorBankId; + private Long ddPayableGlAccountId; + private Long chequeClearingGlAccountId; + private Long cashGlAccountId; + private Long chargesIncomeGlAccountId; + private Long cashSuspenseGlAccountId; + private BigDecimal gstPercentage; + private Long gstPayableGlAccountId; + private BigDecimal ddLimitAmount; + } + + @lombok.Data + @lombok.AllArgsConstructor + public static class RecoBatchResponse { + private Long id; + private String batchReference; + private String status; + private Integer totalRecords; + private Integer successCount; + private Integer mismatchCount; + } + + @lombok.Data + public static class SendAdviceRequest { + private Long sponsorBankId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.java new file mode 100644 index 000000000..0713ebca3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDBatchIssueResult.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.sponsordd.data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Response for submitIssueRequestBatch - tells the maker exactly which physical leaflet (DD number) + * was auto-allocated to each line and for what amount, since that allocation only happens inside the + * submit transaction (locked against concurrent slips) and can't be known beforehand. + */ +public record SponsorDDBatchIssueResult(Long batchId, List lines) { + + public record Line(Long issueRequestId, Long leafletId, String ddNumber, String beneficiaryName, BigDecimal faceValue) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java new file mode 100644 index 000000000..e7146c701 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDCashierDepositOptionData.java @@ -0,0 +1,51 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A cashier's Transaction Entry (AC.TP=GL) deposit request, already approved, credited to the DD + * Cash Suspense GL account, and not yet linked to any Sponsor DD issue request - i.e. available to + * be selected as the cash-receipt evidence for a new CASH DD issuance request. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDCashierDepositOptionData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String voucherNo; + private BigDecimal amount; + private String description; + private String cashierUsername; + private LocalDateTime actionedAt; + // DD face value / charges / GST breakdown captured at Transaction Entry submit time - null for + // deposit requests that were not raised as a DD + Cash + GL row. + private BigDecimal ddFaceValue; + private BigDecimal ddCharges; + private BigDecimal ddGstPercentage; + private BigDecimal ddGstAmount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java new file mode 100644 index 000000000..04a78d787 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java @@ -0,0 +1,50 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDGlMappingData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long sponsorBankId; + private String sponsorBankName; + private Long ddPayableGlAccountId; + private String ddPayableGlAccountName; + private Long chequeClearingGlAccountId; + private String chequeClearingGlAccountName; + private Long cashGlAccountId; + private String cashGlAccountName; + private Long chargesIncomeGlAccountId; + private String chargesIncomeGlAccountName; + private Long cashSuspenseGlAccountId; + private String cashSuspenseGlAccountName; + private BigDecimal gstPercentage; + private Long gstPayableGlAccountId; + private String gstPayableGlAccountName; + private BigDecimal ddLimitAmount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java new file mode 100644 index 000000000..956b7a684 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDInventoryData.java @@ -0,0 +1,45 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDInventoryData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long sponsorBankId; + private String sponsorBankName; + private Long branchId; + private String branchName; + private Long seriesStart; + private Long seriesEnd; + private Integer totalLeaves; + private LocalDate receivedDate; + private String status; + /** Leaflets in this inventory still in AVAILABLE status (not yet issued/cancelled/stopped). */ + private Integer availableLeaves; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java new file mode 100644 index 000000000..7368ea700 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDIssueRequestData.java @@ -0,0 +1,73 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDIssueRequestData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + private Long clientId; + private String clientName; + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private BigDecimal faceValue; + private Long issuingBranchId; + private String issuingBranchName; + private LocalDate issuedDate; + private String paymentMethod; + private Long savingsAccountId; + private Long currentAccountId; + private String chequeNumber; + private String chequeBankName; + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaarMasked; + private BigDecimal chargesAmount; + private BigDecimal gstAmount; + private BigDecimal gstPercentageApplied; + private String status; + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; + private Long resultingSavingsTransactionId; + private Long resultingCurrentTransactionId; + private Long resultingJournalEntryId; + private Long cashReceiptJournalEntryId; + private Long cashRefundJournalEntryId; + /** Groups rows submitted together as one multi-DD slip (see submitIssueRequestBatch). Null for an ordinary single-DD request. */ + private Long batchId; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java new file mode 100644 index 000000000..8e1e75f71 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -0,0 +1,60 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDLeafletData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long inventoryId; + private String ddNumber; + private String status; + private BigDecimal faceValue; + private Long issuedToClientId; + private String issuedToClientName; + private String walkinPurchaserName; + private String walkinPurchaserAddress; + private String walkinPurchaserAadhaarMasked; + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private Long issuingBranchId; + private String issuingBranchName; + private LocalDate issuedDate; + private LocalDate clearedDate; + private String adviceStatus; + private LocalDateTime adviceSentDate; + private Long pendingIssueRequestId; + private Integer printCount; + private Long pendingReprintRequestId; + private Long approvedUnconsumedReprintRequestId; + private Long approverUserId; + private String approverUsername; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java new file mode 100644 index 000000000..b273e4849 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoBatchData.java @@ -0,0 +1,45 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoBatchData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String batchReference; + private String fileName; + private String fileHash; + private String status; + private Integer totalRecords; + private BigDecimal totalAmount; + private Integer successCount; + private Integer mismatchCount; + private String createdByUsername; + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java new file mode 100644 index 000000000..e25d74bdd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRecoRecordData.java @@ -0,0 +1,46 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoRecordData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long batchId; + private String ddNumber; + private BigDecimal clearingAmount; + private LocalDate clearingDate; + private String clearingRefNumber; + private String matchStatus; + private Long matchedLeafletId; + private String remarks; + private String resolvedByUsername; + private LocalDateTime resolvedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java new file mode 100644 index 000000000..ae84a698f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDReprintRequestData.java @@ -0,0 +1,46 @@ +/** + * 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.sponsordd.data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDReprintRequestData implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + private String reason; + private String status; + private Boolean consumed; + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.java new file mode 100644 index 000000000..ca452808e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDSlipLine.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.sponsordd.data; + +import java.math.BigDecimal; + +/** + * One line of a multi-DD "slip" - a customer fills one slip listing several drafts (different + * beneficiaries/amounts, charges optional per line) funded from one account debit or one cheque; the + * combined total across every line is what actually gets deducted. Everything else (payment method, + * account, cheque details, issuing branch, issued date) is shared across the whole slip and carried + * separately, not per line. + */ +public record SponsorDDSlipLine(String beneficiaryName, String payableAtBranch, String payableAtCity, BigDecimal faceValue, + BigDecimal chargesAmount) {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java new file mode 100644 index 000000000..59db2f8d7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java @@ -0,0 +1,63 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_bank") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorBank extends AbstractPersistableCustom { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", length = 255) + private String description; + + @Column(name = "ifsc_code", length = 11) + private String ifscCode; + + @Column(name = "bank_code", length = 20) + private String bankCode; + + @Column(name = "swift_code", length = 11) + private String swiftCode; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java new file mode 100644 index 000000000..0fa2f7a12 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBankRepository.java @@ -0,0 +1,27 @@ +/** + * 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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorBankRepository extends JpaRepository, JpaSpecificationExecutor { + Optional findByName(String name); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java new file mode 100644 index 000000000..47cb68407 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLog.java @@ -0,0 +1,70 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_audit_log") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDAuditLog extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "leaflet_id", nullable = false) + private SponsorDDLeaflet leaflet; + + @Column(name = "action_type", nullable = false, length = 30) + private String actionType; + + @Column(name = "pre_status", length = 20) + private String preStatus; + + @Column(name = "post_status", nullable = false, length = 20) + private String postStatus; + + @ManyToOne + @JoinColumn(name = "changed_by_id", nullable = false) + private AppUser changedBy; + + @Column(name = "client_ip", nullable = false, length = 45) + private String clientIp; + + @Column(name = "payload_snapshot") + private String payloadSnapshot; + + @Column(name = "remarks", length = 255) + private String remarks; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java new file mode 100644 index 000000000..4131beb9d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDAuditLogRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDAuditLogRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java new file mode 100644 index 000000000..6aae865a1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventory.java @@ -0,0 +1,80 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_inventory") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDInventory extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "sponsor_bank_id", nullable = false) + private SponsorBank sponsorBank; + + @ManyToOne + @JoinColumn(name = "branch_id", nullable = false) + private Office branch; + + @Column(name = "series_start", nullable = false) + private Long seriesStart; + + @Column(name = "series_end", nullable = false) + private Long seriesEnd; + + @Column(name = "total_leaves", nullable = false) + private Integer totalLeaves; + + @Column(name = "received_date", nullable = false) + private LocalDate receivedDate; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java new file mode 100644 index 000000000..6a4b070c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDInventoryRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDInventoryRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java new file mode 100644 index 000000000..5aa64a285 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeaflet.java @@ -0,0 +1,117 @@ +/** + * 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.sponsordd.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_leaflet") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDLeaflet extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "inventory_id", nullable = false) + private SponsorDDInventory inventory; + + @Column(name = "dd_number", nullable = false, unique = true, length = 30) + private String ddNumber; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "face_value", nullable = false, precision = 19, scale = 6) + private BigDecimal faceValue; + + @ManyToOne + @JoinColumn(name = "issued_to_client_id") + private Client issuedToClient; + + @Column(name = "beneficiary_name", length = 150) + private String beneficiaryName; + + @Column(name = "payable_at_branch", length = 150) + private String payableAtBranch; + + @Column(name = "payable_at_city", length = 100) + private String payableAtCity; + + /** Set instead of issuedToClient when the purchaser is a walk-in, not a bank client (cash payment). */ + @Column(name = "walkin_purchaser_name", length = 150) + private String walkinPurchaserName; + + @Column(name = "walkin_purchaser_address", length = 255) + private String walkinPurchaserAddress; + + /** Stored masked (only the last 4 digits kept) — never the full Aadhaar number. */ + @Column(name = "walkin_purchaser_aadhaar_masked", length = 20) + private String walkinPurchaserAadhaarMasked; + + @ManyToOne + @JoinColumn(name = "issuing_branch_id") + private Office issuingBranch; + + @Column(name = "issued_date") + private LocalDate issuedDate; + + @Column(name = "cleared_date") + private LocalDate clearedDate; + + @Column(name = "advice_status", nullable = false, length = 20) + private String adviceStatus; + + @Column(name = "advice_sent_date") + private LocalDateTime adviceSentDate; + + @Version + @Column(name = "version", nullable = false) + private Integer version; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java new file mode 100644 index 000000000..ee8e62922 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDLeafletRepository.java @@ -0,0 +1,28 @@ +/** + * 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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDLeafletRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByDdNumber(String ddNumber); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java new file mode 100644 index 000000000..75de7e38d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatch.java @@ -0,0 +1,80 @@ +/** + * 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.sponsordd.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.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_reco_batch") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoBatch extends AbstractPersistableCustom { + + @Column(name = "batch_reference", nullable = false, unique = true, length = 50) + private String batchReference; + + @Column(name = "file_name", nullable = false, length = 150) + private String fileName; + + @Column(name = "file_hash", nullable = false, length = 64) + private String fileHash; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "total_records", nullable = false) + private Integer totalRecords; + + @Column(name = "total_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal totalAmount; + + @Column(name = "success_count", nullable = false) + private Integer successCount; + + @Column(name = "mismatch_count", nullable = false) + private Integer mismatchCount; + + @ManyToOne + @JoinColumn(name = "createdby_id", nullable = false) + private AppUser createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @ManyToOne + @JoinColumn(name = "lastmodifiedby_id") + private AppUser lastModifiedBy; + + @Column(name = "lastmodified_date") + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.java new file mode 100644 index 000000000..0eccd125c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoBatchRepository.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.sponsordd.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDRecoBatchRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByFileHash(String fileHash); + Optional findByBatchReference(String batchReference); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java new file mode 100644 index 000000000..598adfcff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecord.java @@ -0,0 +1,76 @@ +/** + * 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.sponsordd.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.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.useradministration.domain.AppUser; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_reco_record") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRecoRecord extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "batch_id", nullable = false) + private SponsorDDRecoBatch batch; + + @Column(name = "dd_number", nullable = false, length = 30) + private String ddNumber; + + @Column(name = "clearing_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal clearingAmount; + + @Column(name = "clearing_date", nullable = false) + private LocalDate clearingDate; + + @Column(name = "clearing_ref_number", nullable = false, length = 50) + private String clearingRefNumber; + + @Column(name = "match_status", nullable = false, length = 25) + private String matchStatus; + + @ManyToOne + @JoinColumn(name = "matched_leaflet_id") + private SponsorDDLeaflet matchedLeaflet; + + @Column(name = "remarks", length = 255) + private String remarks; + + @ManyToOne + @JoinColumn(name = "resolved_by_id") + private AppUser resolvedBy; + + @Column(name = "resolved_date") + private LocalDateTime resolvedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java new file mode 100644 index 000000000..4bec3c805 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDRecoRecordRepository.java @@ -0,0 +1,28 @@ +/** + * 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.sponsordd.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDRecoRecordRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByBatchId(Long batchId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java new file mode 100644 index 000000000..d7e0f64f7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransaction.java @@ -0,0 +1,69 @@ +/** + * 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.sponsordd.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.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; + +@Getter +@Setter +@Entity +@Table(name = "m_sponsor_dd_transaction") +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDTransaction extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "leaflet_id", nullable = false) + private SponsorDDLeaflet leaflet; + + @ManyToOne + @JoinColumn(name = "savings_transaction_id") + private SavingsAccountTransaction savingsTransaction; + + @ManyToOne + @JoinColumn(name = "current_transaction_id") + private CurrentAccountTransaction currentTransaction; + + @ManyToOne + @JoinColumn(name = "journal_entry_id") + private JournalEntry journalEntry; + + @Column(name = "transaction_type", nullable = false, length = 20) + private String transactionType; + + @Column(name = "amount", nullable = false, precision = 19, scale = 6) + private BigDecimal amount; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java new file mode 100644 index 000000000..600327e15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorDDTransactionRepository.java @@ -0,0 +1,25 @@ +/** + * 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.sponsordd.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SponsorDDTransactionRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java new file mode 100644 index 000000000..b5dcc10c4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -0,0 +1,94 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; + +public interface SponsorDDIssueRequestWritePlatformService { + + /** + * Clerk (maker) step: records a PENDING issue request. + * clientId is required for ACCOUNT_DEBIT/CHEQUE; for CASH it must be null and the walk-in + * purchaser's own details (name, address, raw Aadhaar — masked to last 4 digits before storage) + * are supplied instead, since the purchaser need not be a bank client at all. For CASH, + * cashierDepositRequestId must reference an already-APPROVED cashier deposit request (submitted + * by the cashier via Transaction Entry, AC.TP=GL, target = the DD Cash Suspense GL account) for + * exactly the face value + charges + GST total - the cash itself is never posted by this class; + * it's only validated and linked here, since the cashier's own maker-checker flow already moved + * the money and updated their tracked cash position. + */ + CommandProcessingResult submitIssueRequest(Long leafletId, Long clientId, String walkinPurchaserName, + String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch, + String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate, + String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, + Long cashierDepositRequestId); + + /** + * Manager/head-cashier (checker) step: performs the real debit/credit GL posting + * (client account debit, cheque-clearing GL debit, or cash-in-hand GL debit, credited to + * Sponsor DD Payable — plus a Charges Income credit leg if charges apply), then issues the + * demand draft. + */ + CommandProcessingResult approveIssueRequest(Long issueRequestId); + + CommandProcessingResult rejectIssueRequest(Long issueRequestId, String rejectionReason); + + /** + * Clerk (maker) step for a multi-DD "slip": one customer account debit (or one cheque) funds + * several drafts in one submission - a physical leaflet is auto-allocated per line from the given + * inventory, and every resulting row shares one batch id so the checker approves/rejects the whole + * slip as a unit rather than line by line. CASH is not supported here - only ACCOUNT_DEBIT/CHEQUE, + * since a cash slip would also need Transaction Entry changes to record one lump intake for N + * drafts, which is a separate piece of work. Returns exactly which physical leaflet (DD number) + * was auto-allocated to each line and for what amount, since that allocation only happens inside + * this call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the + * caller beforehand. + */ + SponsorDDBatchIssueResult submitIssueRequestBatch(Long inventoryId, Long clientId, Long issuingBranchId, LocalDate issuedDate, + String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, + List lines); + + /** + * Checker step for a multi-DD slip: one combined withdrawal for the slip's total face value, one + * combined GL journal entry (Dr control/clearing GL for total face value + charges + GST, Cr DD + * Payable / Charges Income / GST Payable), then every line's leaflet is issued and every row in the + * batch moves to APPROVED together. + */ + CommandProcessingResult approveIssueRequestBatch(Long batchId); + + /** Rejects every line of a pending multi-DD slip together; no money has moved yet for ACCOUNT_DEBIT/CHEQUE. */ + CommandProcessingResult rejectIssueRequestBatch(Long batchId, String rejectionReason); + + /** + * Back-office configuration: which GL accounts to use for a given sponsor bank / payment type, + * the GST rate applied to DD issuance charges, and the DD limit amount - kept here as data so it + * can be updated at any time (e.g. when the government revises the GST rate, or compliance + * revises the DD limit) without a code deployment. ddLimitAmount, if set, caps both a single DD's + * face value and the combined face value of all DDs issued to the same beneficiary on the same + * day; null/unset means no limit is enforced. + */ + CommandProcessingResult upsertGlMapping(Long paymentTypeId, Long ddPayableGlAccountId, Long chequeClearingGlAccountId, + Long cashGlAccountId, Long chargesIncomeGlAccountId, Long cashSuspenseGlAccountId, BigDecimal gstPercentage, + Long gstPayableGlAccountId, BigDecimal ddLimitAmount); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..2b1ba3fb0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -0,0 +1,1213 @@ +/** + * 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.sponsordd.service; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT"; + private static final String METHOD_CHEQUE = "CHEQUE"; + private static final String METHOD_CASH = "CASH"; + + // Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing, + // so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit + // aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which + // is trivial to defeat by just inserting a space into the name on a later DD. + private static final String NORMALIZED_NAME_COLUMN = "regexp_replace(upper(beneficiary_name), '[^A-Z0-9]', '', 'g')"; + private static final String NORMALIZED_NAME_PARAM = "regexp_replace(upper(?), '[^A-Z0-9]', '', 'g')"; + private static final int MAX_SLIP_LINES = 20; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; + private final SponsorDDWritePlatformService sponsorDDWritePlatformService; + + // ── Maker step ───────────────────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult submitIssueRequest(final Long leafletId, final Long clientId, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaar, final String beneficiaryName, + final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount, + final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, + final Long currentAccountId, final String chequeNumber, final String chequeBankName, + final Long cashierDepositRequestId) { + + final AppUser currentUser = this.context.authenticatedUser(); + + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"AVAILABLE".equals(leaflet.get("status"))) { + throw new IllegalStateException("Demand Draft leaflet is not available. Current status: " + leaflet.get("status")); + } + if (beneficiaryName == null || beneficiaryName.isBlank()) { + throw new IllegalArgumentException("Beneficiary Name is required."); + } + if (beneficiaryName.length() > 120) { + throw new IllegalArgumentException("Beneficiary Name cannot exceed 120 characters."); + } + if (payableAtBranch == null || payableAtBranch.isBlank()) { + throw new IllegalArgumentException("Payable At - Branch is required."); + } + if (payableAtCity == null || payableAtCity.isBlank()) { + throw new IllegalArgumentException("Payable At - City is required."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_issue_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId).isEmpty()) { + throw new IllegalStateException( + "A DD issuance request is already pending for this leaflet. Wait for it to be approved or rejected before submitting another."); + } + final Long inventoryId = ((Number) leaflet.get("inventory_id")).longValue(); + final Long sponsorBankId = findInventorySponsorBankId(inventoryId); + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + final Map glMapping = findGlMappingOrThrow(paymentMethod, effectiveCharges); + final BigDecimal gstPercentage = (BigDecimal) glMapping.get("gst_percentage"); + final BigDecimal gstAmount = computeGst(effectiveCharges, gstPercentage); + validateDdLimit(glMapping, beneficiaryName, faceValue, issuedDate, null); + validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, + walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaar); + + String walkinPurchaserAadhaarMasked = null; + Long cashReceiptJournalEntryId = null; + if (METHOD_CASH.equals(paymentMethod)) { + walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount); + final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, + cashSuspenseGlAccountId); + cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId); + } else { + validateClientExists(clientId); + if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); + if (currentAccountId != null) validateAccountBelongsToClient("m_current_account", currentAccountId, clientId, "Current account"); + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + // The account is actually debited for face value + charges + GST (see approveIssueRequest), + // so the balance check must cover that same total, not face value alone. + validateSufficientBalance(savingsAccountId, currentAccountId, faceValue.add(effectiveCharges).add(gstAmount)); + } + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + final String finalWalkinAadhaarMasked = walkinPurchaserAadhaarMasked; + final Long finalCashReceiptJournalEntryId = cashReceiptJournalEntryId; + final Long finalCashierDepositRequestId = METHOD_CASH.equals(paymentMethod) ? cashierDepositRequestId : null; + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_issue_request " + + "(leaflet_id, client_id, walkin_purchaser_name, walkin_purchaser_address, walkin_purchaser_aadhaar_masked, " + + "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, " + + "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, " + + "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, " + + "cashier_deposit_request_id, cash_receipt_journal_entry_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); + ps.setString(3, walkinPurchaserName); + ps.setString(4, walkinPurchaserAddress); + ps.setString(5, finalWalkinAadhaarMasked); + ps.setString(6, beneficiaryName); + ps.setString(7, payableAtBranch); + ps.setString(8, payableAtCity); + ps.setBigDecimal(9, faceValue); + ps.setBigDecimal(10, effectiveCharges); + ps.setBigDecimal(11, gstAmount); + ps.setBigDecimal(12, gstPercentage != null ? gstPercentage : BigDecimal.ZERO); + ps.setLong(13, issuingBranchId); + ps.setObject(14, issuedDate); + ps.setString(15, paymentMethod); + if (savingsAccountId != null) ps.setLong(16, savingsAccountId); else ps.setNull(16, java.sql.Types.BIGINT); + if (currentAccountId != null) ps.setLong(17, currentAccountId); else ps.setNull(17, java.sql.Types.BIGINT); + ps.setString(18, chequeNumber); + ps.setString(19, chequeBankName); + ps.setString(20, STATUS_PENDING); + ps.setLong(21, currentUser.getId()); + ps.setObject(22, now); + if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT); + if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT); + return ps; + }, kh); + + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + // ── Maker step: multi-DD slip ─────────────────────────────────────────── + + @Override + @Transactional + public SponsorDDBatchIssueResult submitIssueRequestBatch(final Long inventoryId, final Long clientId, final Long issuingBranchId, + final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, + final String chequeNumber, final String chequeBankName, final List lines) { + + final AppUser currentUser = this.context.authenticatedUser(); + + if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod)) { + throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT or CHEQUE payment methods."); + } + if (lines == null || lines.isEmpty()) { + throw new IllegalArgumentException("At least one DD line is required."); + } + if (lines.size() > MAX_SLIP_LINES) { + throw new IllegalArgumentException("A single DD slip cannot exceed " + MAX_SLIP_LINES + " drafts."); + } + for (final SponsorDDSlipLine line : lines) { + if (line.beneficiaryName() == null || line.beneficiaryName().isBlank()) { + throw new IllegalArgumentException("Beneficiary Name is required for every line."); + } + if (line.beneficiaryName().length() > 120) { + throw new IllegalArgumentException("Beneficiary Name cannot exceed 120 characters."); + } + if (line.payableAtBranch() == null || line.payableAtBranch().isBlank()) { + throw new IllegalArgumentException("Payable At - Branch is required for every line."); + } + if (line.payableAtCity() == null || line.payableAtCity().isBlank()) { + throw new IllegalArgumentException("Payable At - City is required for every line."); + } + if (line.faceValue() == null || line.faceValue().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Face Value must be greater than zero for every line."); + } + } + + validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, + null, null, null); + + final BigDecimal totalFaceValue = lines.stream().map(SponsorDDSlipLine::faceValue).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalCharges = lines.stream() + .map(l -> l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + final Map glMapping = findGlMappingOrThrow(paymentMethod, totalCharges); + final BigDecimal gstPercentage = (BigDecimal) glMapping.get("gst_percentage"); + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + + // Each line's own face value must not exceed the limit on its own... + if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) > 0) { + for (final SponsorDDSlipLine line : lines) { + if (line.faceValue().compareTo(ddLimitAmount) > 0) { + throw new IllegalArgumentException("DD face value (" + line.faceValue() + ") for beneficiary '" + + line.beneficiaryName() + "' exceeds the configured DD limit of " + ddLimitAmount + "."); + } + } + } + // ...and lines in THIS slip for the SAME beneficiary must be added together too, on top of + // whatever that beneficiary already has issued/pending elsewhere today - otherwise splitting a + // large amount across two lines of one slip would defeat the limit just as easily as splitting + // across two separate submissions. + final Map inSlipTotalsByNormalizedName = new java.util.HashMap<>(); + for (final SponsorDDSlipLine line : lines) { + inSlipTotalsByNormalizedName.merge(normalizeBeneficiaryName(line.beneficiaryName()), line.faceValue(), BigDecimal::add); + } + for (final Map.Entry entry : inSlipTotalsByNormalizedName.entrySet()) { + validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, null); + } + + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + validateClientExists(clientId); + if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); + if (currentAccountId != null) validateAccountBelongsToClient("m_current_account", currentAccountId, clientId, "Current account"); + // The account is actually debited for the combined face value + charges + GST across every + // line (see approveIssueRequestBatch), so the balance check must cover that same total. + final BigDecimal totalGstForBalanceCheck = lines.stream() + .map(l -> computeGst(l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO, gstPercentage)) + .reduce(BigDecimal.ZERO, BigDecimal::add); + validateSufficientBalance(savingsAccountId, currentAccountId, totalFaceValue.add(totalCharges).add(totalGstForBalanceCheck)); + } else { + validateClientExists(clientId); + } + + final List> allocatedLeaflets = allocateAvailableLeaflets(inventoryId, lines.size()); + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + Long batchId = null; + final List resultLines = new java.util.ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + final SponsorDDSlipLine line = lines.get(i); + final Long leafletId = ((Number) allocatedLeaflets.get(i).get("id")).longValue(); + final String ddNumber = (String) allocatedLeaflets.get(i).get("dd_number"); + final BigDecimal effectiveCharges = line.chargesAmount() != null ? line.chargesAmount() : BigDecimal.ZERO; + final BigDecimal gstAmount = computeGst(effectiveCharges, gstPercentage); + final Long batchIdForThisRow = batchId; + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_issue_request " + + "(leaflet_id, client_id, beneficiary_name, payable_at_branch, payable_at_city, face_value, " + + "charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, payment_method, " + + "savings_account_id, current_account_id, cheque_number, cheque_bank_name, status, maker_id, " + + "created_date, batch_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setLong(2, clientId); + ps.setString(3, line.beneficiaryName()); + ps.setString(4, line.payableAtBranch()); + ps.setString(5, line.payableAtCity()); + ps.setBigDecimal(6, line.faceValue()); + ps.setBigDecimal(7, effectiveCharges); + ps.setBigDecimal(8, gstAmount); + ps.setBigDecimal(9, gstPercentage != null ? gstPercentage : BigDecimal.ZERO); + ps.setLong(10, issuingBranchId); + ps.setObject(11, issuedDate); + ps.setString(12, paymentMethod); + if (savingsAccountId != null) ps.setLong(13, savingsAccountId); else ps.setNull(13, java.sql.Types.BIGINT); + if (currentAccountId != null) ps.setLong(14, currentAccountId); else ps.setNull(14, java.sql.Types.BIGINT); + ps.setString(15, chequeNumber); + ps.setString(16, chequeBankName); + ps.setString(17, STATUS_PENDING); + ps.setLong(18, currentUser.getId()); + ps.setObject(19, now); + if (batchIdForThisRow != null) ps.setLong(20, batchIdForThisRow); else ps.setNull(20, java.sql.Types.BIGINT); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + if (batchId == null) { + batchId = newId; + this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET batch_id = ? WHERE id = ?", batchId, newId); + } + resultLines.add(new SponsorDDBatchIssueResult.Line(newId, leafletId, ddNumber, line.beneficiaryName(), line.faceValue())); + } + + return new SponsorDDBatchIssueResult(batchId, resultLines); + } + + /** Locks and returns the next `count` AVAILABLE leaflets in this inventory, oldest series first. */ + private List> allocateAvailableLeaflets(final Long inventoryId, final int count) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE inventory_id = ? AND status = 'AVAILABLE' " + + "ORDER BY id ASC LIMIT ? FOR UPDATE", + inventoryId, count); + if (rows.size() < count) { + throw new IllegalStateException("Not enough available leaflets in this inventory: need " + count + + ", only " + rows.size() + " available. Register more inventory or reduce the number of drafts."); + } + return rows; + } + + /** + * The cashier records the physical cash intake themselves via Transaction Entry (AC.TP=GL, + * target = the DD Cash Suspense GL account) - a normal cashier GL deposit that goes through its + * own maker-checker approval and correctly updates the cashier's tracked cash position. This + * validates that request rather than posting a competing GL entry of our own. + */ + private Map validateCashierDepositRequestOrThrow(final Long cashierDepositRequestId, final BigDecimal expectedTotal, + final Long cashSuspenseGlAccountId) { + if (cashierDepositRequestId == null) { + throw new IllegalArgumentException( + "Select the cashier's approved cash-deposit request. Ask the cashier to submit it first via Transaction Entry " + + "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ")."); + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId); + } + final Map deposit = rows.get(0); + if (!"APPROVED".equals(deposit.get("status"))) { + throw new IllegalStateException( + "The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status")); + } + final Object depositGlAccountObj = deposit.get("gl_account_id"); + final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null; + if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) { + throw new IllegalStateException( + "This cashier deposit request wasn't posted to the DD Cash Suspense GL account. Ask the cashier to redo it " + + "via Transaction Entry with AC.TP = GL and the correct target account."); + } + final BigDecimal depositAmount = new BigDecimal(deposit.get("amount").toString()); + if (depositAmount.compareTo(expectedTotal) != 0) { + throw new IllegalStateException("The cashier deposit request amount (" + depositAmount + + ") doesn't match the DD total - face value + charges + GST = " + expectedTotal + "."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?", cashierDepositRequestId).isEmpty()) { + throw new IllegalStateException("This cashier deposit request has already been used for another DD issuance."); + } + return deposit; + } + + private Long findJournalEntryIdForCashierDeposit(final Map depositRequest, final Long cashSuspenseGlAccountId) { + final String txnId = (String) depositRequest.get("txn_id"); + if (txnId == null) return null; + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); + } + + // ── Checker steps ────────────────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult approveIssueRequest(final Long issueRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findIssueRequestOrThrow(issueRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Issue request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own DD issuance request."); + } + + final Long leafletId = ((Number) request.get("leaflet_id")).longValue(); + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"AVAILABLE".equals(leaflet.get("status"))) { + throw new IllegalStateException("Demand Draft leaflet is no longer available. Current status: " + leaflet.get("status")); + } + final String ddNumber = (String) leaflet.get("dd_number"); + final Long inventoryId = ((Number) leaflet.get("inventory_id")).longValue(); + final Long sponsorBankId = findInventorySponsorBankId(inventoryId); + final Long defaultPaymentTypeId = findDefaultPaymentTypeId(); + + final String paymentMethod = (String) request.get("payment_method"); + final BigDecimal chargesAmount = (BigDecimal) request.get("charges_amount"); + // Locked in at submission time - not recomputed from the (possibly since-changed) current + // gst_percentage, so approving a request never applies a different rate than the maker saw. + final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); + final Map glMapping = findGlMappingOrThrow(paymentMethod, chargesAmount); + final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null + ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; + final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null + ? ((Number) glMapping.get("gst_payable_gl_account_id")).longValue() : null; + + final Long clientId = request.get("client_id") != null ? ((Number) request.get("client_id")).longValue() : null; + final String walkinPurchaserName = (String) request.get("walkin_purchaser_name"); + final String walkinPurchaserAddress = (String) request.get("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = (String) request.get("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = (String) request.get("beneficiary_name"); + final String payableAtBranch = (String) request.get("payable_at_branch"); + final String payableAtCity = (String) request.get("payable_at_city"); + final BigDecimal faceValue = (BigDecimal) request.get("face_value"); + final Long issuingBranchId = ((Number) request.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) request.get("issued_date")).toLocalDate(); + + // Re-checked here (not just at submission) in case the configured limit changed, or another + // request for the same beneficiary/day was approved, in the meantime. + validateDdLimit(glMapping, beneficiaryName, faceValue, issuedDate, issueRequestId); + + Long resultingSavingsTransactionId = null; + Long resultingCurrentTransactionId = null; + + final Long drGlAccountId; + Long linkedAccountId = null; + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; + final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; + final String note = "Sponsor DD issuance - DD No " + ddNumber; + // Withdraw the FULL amount actually leaving the customer's account - face value + charges + + // GST - so the real account balance drops by exactly what the combined GL entry below debits + // the control account for. Withdrawing face value alone would under-debit the customer's own + // balance relative to the control GL account by the charges+GST amount on every DD. + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; + final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst); + if (savingsAccountId != null) { + final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); + resultingSavingsTransactionId = Long.valueOf(txnId); + drGlAccountId = getSavingsControlGlId(savingsAccountId); + markWithdrawalGlEntriesReversed("S" + txnId); + linkedAccountId = savingsAccountId; + } else { + final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); + resultingCurrentTransactionId = Long.valueOf(txnId); + drGlAccountId = getCurrentControlGlId(currentAccountId); + markWithdrawalGlEntriesReversed("CAT" + txnId); + linkedAccountId = currentAccountId; + } + if (drGlAccountId == null) { + throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); + } + } else if (METHOD_CHEQUE.equals(paymentMethod)) { + drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } else { + // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the + // request was submitted, so approval clears the suspense rather than debiting cash again. + drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + } + + final String comments = "Sponsor DD issuance - DD No " + ddNumber; + final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + + final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( + leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, + faceValue, issuingBranchId, issuedDate, + resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, + payableAtBranch, payableAtCity); + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, issueRequestId); + + return new CommandProcessingResultBuilder().withEntityId(issueRequestId) + .withSubEntityId(issueResult.getResourceId()).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findIssueRequestOrThrow(issueRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Issue request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own DD issuance request."); + } + + Long cashRefundJournalEntryId = null; + final String paymentMethod = (String) request.get("payment_method"); + if (METHOD_CASH.equals(paymentMethod) && request.get("cash_receipt_journal_entry_id") != null) { + final BigDecimal faceValue = (BigDecimal) request.get("face_value"); + final BigDecimal chargesAmount = (BigDecimal) request.get("charges_amount"); + final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; + // Refund exactly what was received at submission time - the gst_amount locked in then, + // not a recomputation from the (possibly since-changed) current gst_percentage. + final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); + final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; + final Long issuingBranchId = ((Number) request.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) request.get("issued_date")).toLocalDate(); + final Map glMapping = findGlMappingOrThrow(paymentMethod, effectiveCharges); + final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue(); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser"; + cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId, + faceValue.add(effectiveCharges).add(effectiveGst), null, null, issuingBranchId, issuedDate, refundComments, null); + } + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ?, " + + "cash_refund_journal_entry_id = ? WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId); + + return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build(); + } + + // ── Checker steps: multi-DD slip ──────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult approveIssueRequestBatch(final Long batchId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final List> requests = findBatchRequestsOrThrow(batchId); + for (final Map request : requests) { + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException( + "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own DD slip."); + } + } + + final Map first = requests.get(0); + final String paymentMethod = (String) first.get("payment_method"); + final Long issuingBranchId = ((Number) first.get("issuing_branch_id")).longValue(); + final LocalDate issuedDate = ((java.sql.Date) first.get("issued_date")).toLocalDate(); + final Long clientId = first.get("client_id") != null ? ((Number) first.get("client_id")).longValue() : null; + final Long savingsAccountId = first.get("savings_account_id") != null ? ((Number) first.get("savings_account_id")).longValue() : null; + final Long currentAccountId = first.get("current_account_id") != null ? ((Number) first.get("current_account_id")).longValue() : null; + + final BigDecimal totalFaceValue = requests.stream() + .map(r -> (BigDecimal) r.get("face_value")).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalCharges = requests.stream() + .map(r -> (BigDecimal) r.get("charges_amount")).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal totalGst = requests.stream() + .map(r -> (BigDecimal) r.get("gst_amount")).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + + // Re-checked here (not just at submission) in case the configured limit, or other same-day + // activity for these beneficiaries, changed in the meantime - same as the single-DD flow. + final Map glMapping = findGlMappingOrThrow(paymentMethod, totalCharges); + final Map inSlipTotalsByNormalizedName = new java.util.HashMap<>(); + for (final Map request : requests) { + inSlipTotalsByNormalizedName.merge(normalizeBeneficiaryName((String) request.get("beneficiary_name")), + (BigDecimal) request.get("face_value"), BigDecimal::add); + } + for (final Map.Entry entry : inSlipTotalsByNormalizedName.entrySet()) { + validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, batchId); + } + + final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null + ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; + final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null + ? ((Number) glMapping.get("gst_payable_gl_account_id")).longValue() : null; + + final String ddNumbersSummary = requests.stream() + .map(r -> (String) findLeafletOrThrow(((Number) r.get("leaflet_id")).longValue()).get("dd_number")) + .collect(java.util.stream.Collectors.joining(", ")); + + // ONE combined withdrawal for the whole slip - face value + charges + GST across every line - + // so the real account balance drops by exactly what the combined GL entry below debits the + // control account for. + final BigDecimal totalWithdrawal = totalFaceValue.add(totalCharges).add(totalGst); + Long resultingSavingsTransactionId = null; + Long resultingCurrentTransactionId = null; + final Long drGlAccountId; + Long linkedAccountId = null; + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + if (savingsAccountId != null) { + final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); + resultingSavingsTransactionId = Long.valueOf(txnId); + drGlAccountId = getSavingsControlGlId(savingsAccountId); + markWithdrawalGlEntriesReversed("S" + txnId); + linkedAccountId = savingsAccountId; + } else { + final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); + resultingCurrentTransactionId = Long.valueOf(txnId); + drGlAccountId = getCurrentControlGlId(currentAccountId); + markWithdrawalGlEntriesReversed("CAT" + txnId); + linkedAccountId = currentAccountId; + } + if (drGlAccountId == null) { + throw new IllegalStateException( + "Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); + } + } else { + drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } + + final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + + for (final Map request : requests) { + this.sponsorDDWritePlatformService.issueDemandDraft( + ((Number) request.get("leaflet_id")).longValue(), clientId, null, null, null, + (String) request.get("beneficiary_name"), (BigDecimal) request.get("face_value"), issuingBranchId, issuedDate, + resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, + (String) request.get("payable_at_branch"), (String) request.get("payable_at_city")); + } + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE batch_id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, batchId); + + return new CommandProcessingResultBuilder().withEntityId(batchId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectIssueRequestBatch(final Long batchId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final List> requests = findBatchRequestsOrThrow(batchId); + for (final Map request : requests) { + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException( + "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own DD slip."); + } + } + + // No cash refund leg here - a slip only supports ACCOUNT_DEBIT/CHEQUE, neither of which moves + // any money before approval (unlike the single-DD CASH path). + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? " + + "WHERE batch_id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, batchId); + + return new CommandProcessingResultBuilder().withEntityId(batchId).build(); + } + + private List> findBatchRequestsOrThrow(final Long batchId) { + final List> requests = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_issue_request WHERE batch_id = ? ORDER BY id ASC", batchId); + if (requests.isEmpty()) { + throw new IllegalArgumentException("DD slip not found: " + batchId); + } + return requests; + } + + // ── GL mapping configuration ─────────────────────────────────────────── + + @Override + @Transactional + public CommandProcessingResult upsertGlMapping(final Long paymentTypeId, final Long ddPayableGlAccountId, + final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId, + final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, + final BigDecimal ddLimitAmount) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) { + throw new IllegalArgumentException("GST percentage must be between 0 and 100."); + } + if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("DD Limit Amount cannot be negative."); + } + final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); + + final List> existing = this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_gl_mapping LIMIT 1"); + + if (!existing.isEmpty()) { + final long id = ((Number) existing.get(0).get("id")).longValue(); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_gl_mapping SET dd_payable_gl_account_id = ?, cheque_clearing_gl_account_id = ?, " + + "cash_gl_account_id = ?, charges_income_gl_account_id = ?, cash_suspense_gl_account_id = ?, " + + "gst_percentage = ?, gst_payable_gl_account_id = ?, dd_limit_amount = ? WHERE id = ?", + ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId, + cashSuspenseGlAccountId, effectiveGstPercentage, gstPayableGlAccountId, ddLimitAmount, id); + return new CommandProcessingResultBuilder().withEntityId(id).build(); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_gl_mapping (dd_payable_gl_account_id, cheque_clearing_gl_account_id, " + + "cash_gl_account_id, charges_income_gl_account_id, cash_suspense_gl_account_id, gst_percentage, " + + "gst_payable_gl_account_id, dd_limit_amount, created_date) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, ddPayableGlAccountId); + if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT); + if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT); + if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT); + if (cashSuspenseGlAccountId != null) ps.setLong(5, cashSuspenseGlAccountId); else ps.setNull(5, java.sql.Types.BIGINT); + ps.setBigDecimal(6, effectiveGstPercentage); + if (gstPayableGlAccountId != null) ps.setLong(7, gstPayableGlAccountId); else ps.setNull(7, java.sql.Types.BIGINT); + if (ddLimitAmount != null) ps.setBigDecimal(8, ddLimitAmount); else ps.setNull(8, java.sql.Types.DECIMAL); + ps.setObject(9, LocalDateTime.now(java.time.Clock.systemDefaultZone())); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + // ── Validation helpers ───────────────────────────────────────────────── + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Long findInventorySponsorBankId(final Long inventoryId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sponsor_bank_id FROM m_sponsor_dd_inventory WHERE id = ?", inventoryId); + if (rows.isEmpty()) { + throw new IllegalStateException("Sponsor DD inventory not found for this leaflet."); + } + return ((Number) rows.get(0).get("sponsor_bank_id")).longValue(); + } + + private Long findDefaultPaymentTypeId() { + final List> rows = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type LIMIT 1"); + return rows.isEmpty() ? 1L : ((Number) rows.get(0).get("id")).longValue(); + } + + private Map findGlMappingOrThrow(final String paymentMethod, final BigDecimal chargesAmount) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT dd_payable_gl_account_id, cheque_clearing_gl_account_id, cash_gl_account_id, charges_income_gl_account_id, " + + "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1"); + if (rows.isEmpty()) { + throw new IllegalStateException( + "No Sponsor DD GL mapping is configured for this sponsor bank. Ask an administrator to configure the Sponsor DD Payable GL account first."); + } + final Map mapping = rows.get(0); + if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) { + throw new IllegalStateException( + "No cheque-clearing GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cheque payments."); + } + if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) { + throw new IllegalStateException( + "No cash-in-hand GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); + } + if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_suspense_gl_account_id") == null) { + throw new IllegalStateException( + "No cash-received-pending-approval GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); + } + final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; + if (hasCharges && mapping.get("charges_income_gl_account_id") == null) { + throw new IllegalStateException( + "No charges/commission income GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges."); + } + final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage"); + if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0 + && mapping.get("gst_payable_gl_account_id") == null) { + throw new IllegalStateException( + "No GST payable GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges."); + } + return mapping; + } + + /** + * Admin-configured ceiling (m_sponsor_dd_gl_mapping.dd_limit_amount) applied two ways: (1) a + * single DD's own face value must not exceed it, and (2) the combined face value of every DD + * already issued or still pending approval for the same beneficiary name on the same issued date + * - plus this one - must not exceed it either, so a large cash amount can't be split into several + * smaller drafts to the same person/day to dodge the limit. Beneficiary name is matched via + * NORMALIZED_NAME_COLUMN/PARAM (case folded, all non-alphanumerics stripped), so "KK", "K K", + * "K.K." etc. are treated as the same beneficiary rather than being trivially split around the + * check by varying spacing/punctuation. Null/zero-or-negative limit means no limit is + * configured/enforced. + */ + private void validateDdLimit(final Map glMapping, final String beneficiaryName, final BigDecimal faceValue, + final LocalDate issuedDate, final Long excludeIssueRequestId) { + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + if (faceValue.compareTo(ddLimitAmount) > 0) { + throw new IllegalArgumentException( + "DD face value (" + faceValue + ") exceeds the configured DD limit of " + ddLimitAmount + "."); + } + final BigDecimal issuedSoFar = sumIssuedForBeneficiary(beneficiaryName, issuedDate); + final BigDecimal pendingSoFar = sumPendingForBeneficiaryExcludingId(beneficiaryName, issuedDate, excludeIssueRequestId); + throwIfCombinedExceedsLimit(beneficiaryName, issuedDate, faceValue, issuedSoFar.add(pendingSoFar), ddLimitAmount); + } + + /** + * Same DD-limit ceiling, but for a whole multi-DD slip's total against ONE beneficiary at once + * (see submitIssueRequestBatch/approveIssueRequestBatch) instead of a single DD row. batchTotal is + * the sum of every line in THIS slip for this one (already-normalized) beneficiary name - + * excludeBatchId excludes that slip's own still-PENDING rows from the "pending elsewhere" sum + * (they're already folded into batchTotal), while still counting any OTHER pending/issued DDs for + * the same beneficiary/day. + */ + private void validateDdLimitForBatch(final Map glMapping, final String normalizedBeneficiaryName, + final BigDecimal batchTotal, final LocalDate issuedDate, final Long excludeBatchId) { + final BigDecimal ddLimitAmount = (BigDecimal) glMapping.get("dd_limit_amount"); + if (ddLimitAmount == null || ddLimitAmount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + final BigDecimal issuedSoFar = sumIssuedForBeneficiary(normalizedBeneficiaryName, issuedDate); + final BigDecimal pendingSoFar = sumPendingForBeneficiaryExcludingBatch(normalizedBeneficiaryName, issuedDate, excludeBatchId); + throwIfCombinedExceedsLimit(normalizedBeneficiaryName, issuedDate, batchTotal, issuedSoFar.add(pendingSoFar), ddLimitAmount); + } + + private void throwIfCombinedExceedsLimit(final String beneficiaryName, final LocalDate issuedDate, final BigDecimal thisAmount, + final BigDecimal alreadyCommitted, final BigDecimal ddLimitAmount) { + final BigDecimal combinedTotal = alreadyCommitted.add(thisAmount); + if (combinedTotal.compareTo(ddLimitAmount) > 0) { + throw new IllegalStateException("Combined DD face value for beneficiary '" + beneficiaryName + "' on " + issuedDate + + " (already issued/pending " + alreadyCommitted + " + this DD " + thisAmount + " = " + combinedTotal + + ") would exceed the configured DD limit of " + ddLimitAmount + "."); + } + } + + private BigDecimal sumIssuedForBeneficiary(final String beneficiaryName, final LocalDate issuedDate) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_leaflet " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'ISSUED'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + private BigDecimal sumPendingForBeneficiaryExcludingId(final String beneficiaryName, final LocalDate issuedDate, + final Long excludeIssueRequestId) { + if (excludeIssueRequestId != null) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING' AND id <> ?", + BigDecimal.class, beneficiaryName, issuedDate, excludeIssueRequestId); + } + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + private BigDecimal sumPendingForBeneficiaryExcludingBatch(final String beneficiaryName, final LocalDate issuedDate, + final Long excludeBatchId) { + if (excludeBatchId != null) { + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING' AND (batch_id IS NULL OR batch_id <> ?)", + BigDecimal.class, beneficiaryName, issuedDate, excludeBatchId); + } + return this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(face_value), 0) FROM m_sponsor_dd_issue_request " + + "WHERE " + NORMALIZED_NAME_COLUMN + " = " + NORMALIZED_NAME_PARAM + + " AND issued_date = ? AND status = 'PENDING'", + BigDecimal.class, beneficiaryName, issuedDate); + } + + /** Mirrors NORMALIZED_NAME_COLUMN/PARAM in Java, for grouping a batch's own lines in memory. */ + private static String normalizeBeneficiaryName(final String name) { + return name == null ? "" : name.toUpperCase().replaceAll("[^A-Z0-9]", ""); + } + + /** GST is charged on the DD issuance commission/charges only, never on the DD face value. */ + private static BigDecimal computeGst(final BigDecimal chargesAmount, final BigDecimal gstPercentage) { + if (chargesAmount == null || gstPercentage == null || chargesAmount.compareTo(BigDecimal.ZERO) <= 0 + || gstPercentage.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + return chargesAmount.multiply(gstPercentage).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); + } + + private void validateClientExists(final Long clientId) { + final List> rows = this.jdbcTemplate.queryForList("SELECT id FROM m_client WHERE id = ?", clientId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Client not found with ID: " + clientId + + ". Enter the client's internal ID (visible in the URL on their profile page), not their account number."); + } + } + + private void validateAccountBelongsToClient(final String tableName, final Long accountId, final Long clientId, final String label) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT client_id, account_no FROM " + tableName + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException(label + " not found with ID: " + accountId + + ". Enter the account's internal ID, not its printed account number."); + } + final Object ownerClientId = rows.get(0).get("client_id"); + if (ownerClientId == null || ((Number) ownerClientId).longValue() != clientId.longValue()) { + throw new IllegalArgumentException(label + " " + accountId + " (account no. " + rows.get(0).get("account_no") + + ") does not belong to client " + clientId + "."); + } + } + + /** + * Mirrors the balance check Fineract's own withdrawal command runs at approval time + * (running balance + overdraft limit, if allowed - minimum required balance, if enforced), + * but at submission time so a clerk gets an immediate error instead of the manager discovering + * insufficient funds only when trying to approve. withdrawalAmount must be the FULL amount that + * will actually leave the account at approval - face value + charges + GST - matching what + * executeSavingsWithdrawal/executeCurrentAccountWithdrawal actually withdraw. + */ + private void validateSufficientBalance(final Long savingsAccountId, final Long currentAccountId, final BigDecimal withdrawalAmount) { + final String tableName = savingsAccountId != null ? "m_savings_account" : "m_current_account"; + final Long accountId = savingsAccountId != null ? savingsAccountId : currentAccountId; + final List> rows = this.jdbcTemplate.queryForList( + "SELECT account_balance_derived, min_required_balance, enforce_min_required_balance, allow_overdraft, overdraft_limit " + + "FROM " + tableName + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Account not found with ID: " + accountId); + } + final Map row = rows.get(0); + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + final boolean allowOverdraft = Boolean.TRUE.equals(row.get("allow_overdraft")); + final BigDecimal overdraftLimit = allowOverdraft && row.get("overdraft_limit") != null + ? (BigDecimal) row.get("overdraft_limit") : BigDecimal.ZERO; + final BigDecimal withdrawable = balance.add(overdraftLimit).subtract(minRequired); + if (withdrawalAmount.compareTo(withdrawable) > 0) { + final StringBuilder message = new StringBuilder("Insufficient balance in the customer's account for this DD. ") + .append("Account balance: ").append(balance); + if (minRequired.compareTo(BigDecimal.ZERO) > 0) { + message.append(", minus minimum balance to maintain: ").append(minRequired); + } + if (overdraftLimit.compareTo(BigDecimal.ZERO) > 0) { + message.append(", plus overdraft limit: ").append(overdraftLimit); + } + message.append(" = amount available to withdraw: ").append(withdrawable) + .append(". DD amount requested (face value + charges + GST): ").append(withdrawalAmount).append("."); + throw new IllegalStateException(message.toString()); + } + } + + private void validatePaymentMethodFields(final String paymentMethod, final Long clientId, final Long savingsAccountId, + final Long currentAccountId, final String chequeNumber, final String chequeBankName, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaar) { + if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { + if (clientId == null) { + throw new IllegalArgumentException("Client ID is required for an account-debit DD issuance."); + } + final boolean hasSavings = savingsAccountId != null; + final boolean hasCurrent = currentAccountId != null; + if (hasSavings == hasCurrent) { + throw new IllegalArgumentException("Provide exactly one of Savings Account or Current Account for an account-debit DD issuance."); + } + } else if (METHOD_CHEQUE.equals(paymentMethod)) { + if (clientId == null) { + throw new IllegalArgumentException("Client ID is required for a cheque-funded DD issuance."); + } + if (chequeNumber == null || chequeNumber.isBlank() || chequeBankName == null || chequeBankName.isBlank()) { + throw new IllegalArgumentException("Cheque Number and Cheque Bank Name are required for a cheque-funded DD issuance."); + } + if (savingsAccountId != null || currentAccountId != null) { + throw new IllegalArgumentException("Do not provide a Savings/Current Account when paying by cheque."); + } + } else if (METHOD_CASH.equals(paymentMethod)) { + if (clientId != null) { + throw new IllegalArgumentException( + "Do not provide a Client ID for a cash DD issuance — capture the walk-in purchaser's own details instead."); + } + if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) { + throw new IllegalArgumentException("Do not provide an account or cheque details when paying by cash."); + } + if (walkinPurchaserName == null || walkinPurchaserName.isBlank() + || walkinPurchaserAddress == null || walkinPurchaserAddress.isBlank() + || walkinPurchaserAadhaar == null || walkinPurchaserAadhaar.isBlank()) { + throw new IllegalArgumentException("Purchaser Name, Address and Aadhaar Number are required for a cash DD issuance."); + } + if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) { + throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits."); + } + } else { + throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH."); + } + } + + /** Aadhaar is never stored in full — only the last 4 digits are retained, per UIDAI/RBI guidance. */ + private static String maskAadhaar(final String aadhaar) { + final String digitsOnly = aadhaar.replaceAll("\\s", ""); + final String last4 = digitsOnly.substring(digitsOnly.length() - 4); + return "XXXX-XXXX-" + last4; + } + + private Map findIssueRequestOrThrow(final Long issueRequestId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_issue_request WHERE id = ?", issueRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD issue request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) return true; + return user.getRoles().stream() + .map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve, reject, or configure Sponsor DD GL mappings."); + } + } + + // ── Command / GL posting helpers (mirrors the pattern used by the Clerk Disbursement workstation) ── + + private String executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, + final Long paymentTypeId, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", paymentTypeId); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .savingsAccountWithdrawal(savingsAccountId) + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId().toString(); + } + + private String executeCurrentAccountWithdrawal(final Long currentAccountId, final BigDecimal amount, final LocalDate txnDate, + final Long paymentTypeId, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", paymentTypeId); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .currentAccountWithdrawal(currentAccountId) + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId().toString(); + } + + /** + * A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry — + * but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank. + * Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement + * module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD + * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. + */ + private void markWithdrawalGlEntriesReversed(final String transactionId) { + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId); + } + + /** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */ + private Long getSavingsControlGlId(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT apm.gl_account_id FROM m_savings_account sa " + + "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id " + + "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + savingsAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + /** Returns the Savings Control GL account ID for this current account's product (from acc_product_mapping). */ + private Long getCurrentControlGlId(final Long currentAccountId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT apm.gl_account_id FROM m_current_account ca " + + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " + + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + currentAccountId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); + } + + /** + * Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for + * (amount + chargesAmount) / Cr crGlAccountId for amount / Cr chargesGlAccountId for chargesAmount + * (only if chargesAmount > 0 — charges are the bank's fee income, not part of the Sponsor DD Payable + * liability, so they get their own credit leg rather than being folded into the liability). Resolves + * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / + * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's + * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / + * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching + * how Fineract's own auto-generated entries display. + */ + private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, + final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, + final String comments, final Long linkedAccountId) { + return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, + txnDate, comments, linkedAccountId); + } + + /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ + private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, + final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + + final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; + final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; + BigDecimal drAmount = amount; + if (hasCharges) drAmount = drAmount.add(chargesAmount); + if (hasGst) drAmount = drAmount.add(gstAmount); + + final com.google.gson.JsonArray debits = new com.google.gson.JsonArray(); + final JsonObject debitEntry = new JsonObject(); + debitEntry.addProperty("glAccountId", drGlAccountId); + debitEntry.addProperty("amount", drAmount); + debits.add(debitEntry); + + final com.google.gson.JsonArray credits = new com.google.gson.JsonArray(); + final JsonObject creditEntry = new JsonObject(); + creditEntry.addProperty("glAccountId", crGlAccountId); + creditEntry.addProperty("amount", amount); + credits.add(creditEntry); + + if (hasCharges) { + final JsonObject chargesCreditEntry = new JsonObject(); + chargesCreditEntry.addProperty("glAccountId", chargesGlAccountId); + chargesCreditEntry.addProperty("amount", chargesAmount); + credits.add(chargesCreditEntry); + } + + if (hasGst) { + final JsonObject gstCreditEntry = new JsonObject(); + gstCreditEntry.addProperty("glAccountId", gstGlAccountId); + gstCreditEntry.addProperty("amount", gstAmount); + credits.add(gstCreditEntry); + } + + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", officeId); + payload.addProperty("transactionDate", formatForFineract(txnDate)); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", "INR"); + payload.addProperty("comments", comments); + payload.add("debits", debits); + payload.add("credits", credits); + + final CommandWrapper cmd = new CommandWrapperBuilder() + .createJournalEntry() + .withJson(payload.toString()) + .build(); + this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + final String transactionId = result != null ? result.getTransactionId() : null; + if (transactionId == null) return null; + + if (linkedAccountId != null) { + this.jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2", + linkedAccountId, transactionId); + } + + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId); + return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); + } + + private String formatForFineract(final LocalDate date) { + return date.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java new file mode 100644 index 000000000..b013e284b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -0,0 +1,67 @@ +/** + * 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.sponsordd.service; + +import java.util.Collection; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; + +public interface SponsorDDReadPlatformService { + + Collection retrieveAllInventories(); + + SponsorDDInventoryData retrieveInventory(Long inventoryId); + + Collection retrieveAllLeafletsByInventory(Long inventoryId); + + SponsorDDLeafletData retrieveLeaflet(Long leafletId); + + SponsorDDLeafletData retrieveLeafletByDdNumber(String ddNumber); + + Collection retrieveLeaflets(Long sponsorBankId, String status, String adviceStatus); + + Collection retrieveAllReconciliationBatches(); + + org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(Long batchId); + + Collection retrieveReconciliationRecordsByBatch(Long batchId); + + Collection retrieveAllGlMappings(); + + SponsorDDGlMappingData retrieveGlMappingBySponsorBank(Long sponsorBankId); + + Collection retrieveAllIssueRequests(String status); + + Collection retrieveMyIssueRequests(Long makerId); + + SponsorDDIssueRequestData retrieveIssueRequest(Long issueRequestId); + + Collection retrieveAllReprintRequests(String status); + + /** + * Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash + * Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk + * can pick from when submitting a new CASH DD issuance request. + */ + Collection retrieveAvailableCashierDepositRequests(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java new file mode 100644 index 000000000..010b6bee4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -0,0 +1,465 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final InventoryMapper inventoryMapper = new InventoryMapper(); + private final LeafletMapper leafletMapper = new LeafletMapper(); + + @Autowired + public SponsorDDReadPlatformServiceImpl(final JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private static final String INVENTORY_SELECT = + "select inv.id, inv.sponsor_bank_id, sb.name as sponsor_bank_name, inv.branch_id, o.name as branch_name, " + + "inv.series_start, inv.series_end, inv.total_leaves, inv.received_date, inv.status, " + + "(select count(*) from m_sponsor_dd_leaflet lf where lf.inventory_id = inv.id and lf.status = 'AVAILABLE') as available_leaves " + + "from m_sponsor_dd_inventory inv " + + "join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id " + + "join m_office o on o.id = inv.branch_id "; + + @Override + public Collection retrieveAllInventories() { + final String sql = INVENTORY_SELECT + "order by inv.id desc"; + return this.jdbcTemplate.query(sql, this.inventoryMapper); + } + + @Override + public SponsorDDInventoryData retrieveInventory(final Long inventoryId) { + try { + final String sql = INVENTORY_SELECT + "where inv.id = ?"; + return this.jdbcTemplate.queryForObject(sql, this.inventoryMapper, inventoryId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final String LEAFLET_SELECT = + "select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, " + + "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, " + + "leaf.walkin_purchaser_aadhaar_masked, leaf.beneficiary_name, leaf.payable_at_branch, leaf.payable_at_city, " + + "leaf.issuing_branch_id, o.name as branch_name, " + + "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, " + + "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, " + + "apr.checker_id as approver_user_id, approver.username as approver_username " + + "from m_sponsor_dd_leaflet leaf " + + "left join m_client c on c.id = leaf.issued_to_client_id " + + "left join m_office o on o.id = leaf.issuing_branch_id " + + "left join m_sponsor_dd_issue_request pir on pir.leaflet_id = leaf.id and pir.status = 'PENDING' " + + "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' " + + "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false " + + "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' " + + "left join m_appuser approver on approver.id = apr.checker_id "; + + @Override + public Collection retrieveAllLeafletsByInventory(final Long inventoryId) { + final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc"; + return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId); + } + + @Override + public SponsorDDLeafletData retrieveLeaflet(final Long leafletId) { + try { + final String sql = LEAFLET_SELECT + "where leaf.id = ?"; + return this.jdbcTemplate.queryForObject(sql, this.leafletMapper, leafletId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + @Override + public SponsorDDLeafletData retrieveLeafletByDdNumber(final String ddNumber) { + try { + final String sql = LEAFLET_SELECT + "where leaf.dd_number = ?"; + return this.jdbcTemplate.queryForObject(sql, this.leafletMapper, ddNumber); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class InventoryMapper implements RowMapper { + @Override + public SponsorDDInventoryData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long sponsorBankId = rs.getLong("sponsor_bank_id"); + final String sponsorBankName = rs.getString("sponsor_bank_name"); + final Long branchId = rs.getLong("branch_id"); + final String branchName = rs.getString("branch_name"); + final Long seriesStart = rs.getLong("series_start"); + final Long seriesEnd = rs.getLong("series_end"); + final Integer totalLeaves = rs.getInt("total_leaves"); + final LocalDate receivedDate = rs.getDate("received_date") != null ? rs.getDate("received_date").toLocalDate() : null; + final String status = rs.getString("status"); + final Integer availableLeaves = rs.getInt("available_leaves"); + return new SponsorDDInventoryData(id, sponsorBankId, sponsorBankName, branchId, branchName, seriesStart, seriesEnd, totalLeaves, receivedDate, status, availableLeaves); + } + } + + private static final class LeafletMapper implements RowMapper { + @Override + public SponsorDDLeafletData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long inventoryId = rs.getLong("inventory_id"); + final String ddNumber = rs.getString("dd_number"); + final String status = rs.getString("status"); + final BigDecimal faceValue = rs.getBigDecimal("face_value"); + final Long issuedToClientId = rs.getObject("issued_to_client_id") != null ? rs.getLong("issued_to_client_id") : null; + final String clientName = rs.getString("client_name"); + final String walkinPurchaserName = rs.getString("walkin_purchaser_name"); + final String walkinPurchaserAddress = rs.getString("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = rs.getString("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = rs.getString("beneficiary_name"); + final String payableAtBranch = rs.getString("payable_at_branch"); + final String payableAtCity = rs.getString("payable_at_city"); + final Long issuingBranchId = rs.getObject("issuing_branch_id") != null ? rs.getLong("issuing_branch_id") : null; + final String branchName = rs.getString("branch_name"); + final LocalDate issuedDate = rs.getDate("issued_date") != null ? rs.getDate("issued_date").toLocalDate() : null; + final LocalDate clearedDate = rs.getDate("cleared_date") != null ? rs.getDate("cleared_date").toLocalDate() : null; + final String adviceStatus = rs.getString("advice_status"); + final LocalDateTime adviceSentDate = rs.getTimestamp("advice_sent_date") != null ? rs.getTimestamp("advice_sent_date").toLocalDateTime() : null; + final Long pendingIssueRequestId = rs.getObject("pending_issue_request_id") != null ? rs.getLong("pending_issue_request_id") : null; + final Integer printCount = rs.getObject("print_count") != null ? rs.getInt("print_count") : 0; + final Long pendingReprintRequestId = rs.getObject("pending_reprint_request_id") != null ? rs.getLong("pending_reprint_request_id") : null; + final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null; + final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null; + final String approverUsername = rs.getString("approver_username"); + return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName, + walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, + payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, + pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId, + approverUserId, approverUsername); + } + } + + @Override + public Collection retrieveAllReconciliationBatches() { + final String sql = "select b.id, b.batch_reference, b.file_name, b.file_hash, b.status, b.total_records, b.total_amount, b.success_count, b.mismatch_count, u.username as maker_username, b.created_date from m_sponsor_dd_reco_batch b join m_appuser u on u.id = b.createdby_id order by b.id desc"; + return this.jdbcTemplate.query(sql, new RecoBatchMapper()); + } + + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(final Long batchId) { + try { + final String sql = "select b.id, b.batch_reference, b.file_name, b.file_hash, b.status, b.total_records, b.total_amount, b.success_count, b.mismatch_count, u.username as maker_username, b.created_date from m_sponsor_dd_reco_batch b join m_appuser u on u.id = b.createdby_id where b.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new RecoBatchMapper(), batchId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + @Override + public Collection retrieveReconciliationRecordsByBatch(final Long batchId) { + final String sql = "select r.id, r.batch_id, r.dd_number, r.clearing_amount, r.clearing_date, r.clearing_ref_number, r.match_status, r.matched_leaflet_id, r.remarks, u.username as resolver_username, r.resolved_date from m_sponsor_dd_reco_record r left join m_appuser u on u.id = r.resolved_by_id where r.batch_id = ? order by r.id asc"; + return this.jdbcTemplate.query(sql, new RecoRecordMapper(), batchId); + } + + private static final class RecoBatchMapper implements RowMapper { + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final String batchReference = rs.getString("batch_reference"); + final String fileName = rs.getString("file_name"); + final String fileHash = rs.getString("file_hash"); + final String status = rs.getString("status"); + final Integer totalRecords = rs.getInt("total_records"); + final BigDecimal totalAmount = rs.getBigDecimal("total_amount"); + final Integer successCount = rs.getInt("success_count"); + final Integer mismatchCount = rs.getInt("mismatch_count"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + return new org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData(id, batchReference, fileName, fileHash, status, totalRecords, totalAmount, successCount, mismatchCount, makerUsername, createdDate); + } + } + + private static final class RecoRecordMapper implements RowMapper { + @Override + public org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long batchId = rs.getLong("batch_id"); + final String ddNumber = rs.getString("dd_number"); + final BigDecimal clearingAmount = rs.getBigDecimal("clearing_amount"); + final LocalDate clearingDate = rs.getDate("clearing_date") != null ? rs.getDate("clearing_date").toLocalDate() : null; + final String clearingRefNumber = rs.getString("clearing_ref_number"); + final String matchStatus = rs.getString("match_status"); + final Long matchedLeafletId = rs.getObject("matched_leaflet_id") != null ? rs.getLong("matched_leaflet_id") : null; + final String remarks = rs.getString("remarks"); + final String resolverUsername = rs.getString("resolver_username"); + final LocalDateTime resolvedDate = rs.getTimestamp("resolved_date") != null ? rs.getTimestamp("resolved_date").toLocalDateTime() : null; + return new org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData(id, batchId, ddNumber, clearingAmount, clearingDate, clearingRefNumber, matchStatus, matchedLeafletId, remarks, resolverUsername, resolvedDate); + } + } + + private static final String GL_MAPPING_SELECT = + "select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, " + + "payable.name as dd_payable_gl_account_name, m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, " + + "m.cash_gl_account_id, cash.name as cash_gl_account_name, m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, " + + "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, " + + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, " + + "m.dd_limit_amount " + + "from m_sponsor_dd_gl_mapping m " + + "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + + "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id " + + "left join acc_gl_account cash on cash.id = m.cash_gl_account_id " + + "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id " + + "left join acc_gl_account suspense on suspense.id = m.cash_suspense_gl_account_id " + + "left join acc_gl_account gst on gst.id = m.gst_payable_gl_account_id "; + + @Override + public Collection retrieveAllGlMappings() { + final String sql = GL_MAPPING_SELECT; + return this.jdbcTemplate.query(sql, new GlMappingMapper()); + } + + @Override + public SponsorDDGlMappingData retrieveGlMappingBySponsorBank(final Long sponsorBankId) { + try { + final String sql = GL_MAPPING_SELECT + "limit 1"; + return this.jdbcTemplate.queryForObject(sql, new GlMappingMapper()); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class GlMappingMapper implements RowMapper { + @Override + public SponsorDDGlMappingData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null; + final String sponsorBankName = rs.getString("sponsor_bank_name"); + final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); + final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name"); + final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null; + final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name"); + final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null; + final String cashGlAccountName = rs.getString("cash_gl_account_name"); + final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null; + final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name"); + final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null; + final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name"); + final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage"); + final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null; + final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name"); + final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount"); + return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, + chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName, + chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName, + gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount); + } + } + + private static final String ISSUE_REQUEST_SELECT = + "select r.id, r.leaflet_id, leaf.dd_number, r.client_id, c.display_name as client_name, r.walkin_purchaser_name, " + + "r.walkin_purchaser_address, r.walkin_purchaser_aadhaar_masked, r.beneficiary_name, r.payable_at_branch, " + + "r.payable_at_city, " + + "r.face_value, r.issuing_branch_id, o.name as branch_name, r.issued_date, r.payment_method, r.savings_account_id, " + + "r.current_account_id, r.cheque_number, r.cheque_bank_name, r.charges_amount, r.gst_amount, r.gst_percentage_applied, " + + "r.status, r.maker_id, maker.username as maker_username, " + + "r.created_date, r.checker_id, checker.username as checker_username, r.checker_date, r.rejection_reason, " + + "r.resulting_savings_transaction_id, r.resulting_current_transaction_id, r.resulting_journal_entry_id, " + + "r.cash_receipt_journal_entry_id, r.cash_refund_journal_entry_id, r.batch_id " + + "from m_sponsor_dd_issue_request r " + + "join m_sponsor_dd_leaflet leaf on leaf.id = r.leaflet_id " + + "left join m_client c on c.id = r.client_id " + + "join m_office o on o.id = r.issuing_branch_id " + + "join m_appuser maker on maker.id = r.maker_id " + + "left join m_appuser checker on checker.id = r.checker_id "; + + @Override + public Collection retrieveAllIssueRequests(final String status) { + if (status != null && !status.isBlank()) { + final String sql = ISSUE_REQUEST_SELECT + "where r.status = ? order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper(), status); + } + final String sql = ISSUE_REQUEST_SELECT + "order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper()); + } + + @Override + public Collection retrieveMyIssueRequests(final Long makerId) { + final String sql = ISSUE_REQUEST_SELECT + "where r.maker_id = ? order by r.id desc"; + return this.jdbcTemplate.query(sql, new IssueRequestMapper(), makerId); + } + + @Override + public SponsorDDIssueRequestData retrieveIssueRequest(final Long issueRequestId) { + try { + final String sql = ISSUE_REQUEST_SELECT + "where r.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new IssueRequestMapper(), issueRequestId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private static final class IssueRequestMapper implements RowMapper { + @Override + public SponsorDDIssueRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long leafletId = rs.getLong("leaflet_id"); + final String ddNumber = rs.getString("dd_number"); + final Long clientId = rs.getObject("client_id") != null ? rs.getLong("client_id") : null; + final String clientName = rs.getString("client_name"); + final String walkinPurchaserName = rs.getString("walkin_purchaser_name"); + final String walkinPurchaserAddress = rs.getString("walkin_purchaser_address"); + final String walkinPurchaserAadhaarMasked = rs.getString("walkin_purchaser_aadhaar_masked"); + final String beneficiaryName = rs.getString("beneficiary_name"); + final String payableAtBranch = rs.getString("payable_at_branch"); + final String payableAtCity = rs.getString("payable_at_city"); + final BigDecimal faceValue = rs.getBigDecimal("face_value"); + final Long issuingBranchId = rs.getLong("issuing_branch_id"); + final String branchName = rs.getString("branch_name"); + final LocalDate issuedDate = rs.getDate("issued_date") != null ? rs.getDate("issued_date").toLocalDate() : null; + final String paymentMethod = rs.getString("payment_method"); + final Long savingsAccountId = rs.getObject("savings_account_id") != null ? rs.getLong("savings_account_id") : null; + final Long currentAccountId = rs.getObject("current_account_id") != null ? rs.getLong("current_account_id") : null; + final String chequeNumber = rs.getString("cheque_number"); + final String chequeBankName = rs.getString("cheque_bank_name"); + final BigDecimal chargesAmount = rs.getBigDecimal("charges_amount"); + final BigDecimal gstAmount = rs.getBigDecimal("gst_amount"); + final BigDecimal gstPercentageApplied = rs.getBigDecimal("gst_percentage_applied"); + final String status = rs.getString("status"); + final Long makerId = rs.getLong("maker_id"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null; + final String checkerUsername = rs.getString("checker_username"); + final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null; + final String rejectionReason = rs.getString("rejection_reason"); + final Long resultingSavingsTransactionId = rs.getObject("resulting_savings_transaction_id") != null ? rs.getLong("resulting_savings_transaction_id") : null; + final Long resultingCurrentTransactionId = rs.getObject("resulting_current_transaction_id") != null ? rs.getLong("resulting_current_transaction_id") : null; + final Long resultingJournalEntryId = rs.getObject("resulting_journal_entry_id") != null ? rs.getLong("resulting_journal_entry_id") : null; + final Long cashReceiptJournalEntryId = rs.getObject("cash_receipt_journal_entry_id") != null ? rs.getLong("cash_receipt_journal_entry_id") : null; + final Long cashRefundJournalEntryId = rs.getObject("cash_refund_journal_entry_id") != null ? rs.getLong("cash_refund_journal_entry_id") : null; + final Long batchId = rs.getObject("batch_id") != null ? rs.getLong("batch_id") : null; + return new SponsorDDIssueRequestData(id, leafletId, ddNumber, clientId, clientName, beneficiaryName, payableAtBranch, + payableAtCity, faceValue, issuingBranchId, branchName, issuedDate, paymentMethod, savingsAccountId, + currentAccountId, chequeNumber, chequeBankName, walkinPurchaserName, walkinPurchaserAddress, + walkinPurchaserAadhaarMasked, chargesAmount, gstAmount, gstPercentageApplied, status, makerId, makerUsername, + createdDate, checkerId, checkerUsername, checkerDate, rejectionReason, resultingSavingsTransactionId, + resultingCurrentTransactionId, resultingJournalEntryId, cashReceiptJournalEntryId, cashRefundJournalEntryId, + batchId); + } + } + + private static final String REPRINT_REQUEST_SELECT = + "select rr.id, rr.leaflet_id, leaf.dd_number, rr.reason, rr.status, rr.consumed, rr.maker_id, " + + "maker.username as maker_username, rr.created_date, rr.checker_id, checker.username as checker_username, " + + "rr.checker_date, rr.rejection_reason " + + "from m_sponsor_dd_reprint_request rr " + + "join m_sponsor_dd_leaflet leaf on leaf.id = rr.leaflet_id " + + "join m_appuser maker on maker.id = rr.maker_id " + + "left join m_appuser checker on checker.id = rr.checker_id "; + + @Override + public Collection retrieveAllReprintRequests(final String status) { + if (status != null && !status.isBlank()) { + final String sql = REPRINT_REQUEST_SELECT + "where rr.status = ? order by rr.id desc"; + return this.jdbcTemplate.query(sql, new ReprintRequestMapper(), status); + } + final String sql = REPRINT_REQUEST_SELECT + "order by rr.id desc"; + return this.jdbcTemplate.query(sql, new ReprintRequestMapper()); + } + + private static final class ReprintRequestMapper implements RowMapper { + @Override + public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long leafletId = rs.getLong("leaflet_id"); + final String ddNumber = rs.getString("dd_number"); + final String reason = rs.getString("reason"); + final String status = rs.getString("status"); + final Boolean consumed = rs.getBoolean("consumed"); + final Long makerId = rs.getLong("maker_id"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null; + final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null; + final String checkerUsername = rs.getString("checker_username"); + final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null; + final String rejectionReason = rs.getString("rejection_reason"); + return new SponsorDDReprintRequestData(id, leafletId, ddNumber, reason, status, consumed, makerId, makerUsername, + createdDate, checkerId, checkerUsername, checkerDate, rejectionReason); + } + } + + @Override + public Collection retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) { + final List params = new ArrayList<>(); + String sql = LEAFLET_SELECT + "join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id WHERE 1=1 "; + if (sponsorBankId != null) { + sql += " AND inv.sponsor_bank_id = ? "; + params.add(sponsorBankId); + } + if (status != null) { + sql += " AND leaf.status = ? "; + params.add(status); + } + if (adviceStatus != null) { + sql += " AND leaf.advice_status = ? "; + params.add(adviceStatus); + } + sql += " ORDER BY leaf.dd_number ASC"; + return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray()); + } + + @Override + public Collection retrieveAvailableCashierDepositRequests() { + final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " + + "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount " + + "from m_cashier_deposit_request cdr " + + "where cdr.status = 'APPROVED' " + + "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) " + + "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request " + + "where cashier_deposit_request_id is not null) " + + "order by cdr.actioned_at desc"; + return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData( + rs.getLong("id"), + rs.getString("voucher_no"), + rs.getBigDecimal("amount"), + rs.getString("description"), + rs.getString("cashier_username"), + rs.getTimestamp("actioned_at") != null ? rs.getTimestamp("actioned_at").toLocalDateTime() : null, + rs.getBigDecimal("dd_face_value"), + rs.getBigDecimal("dd_charges"), + rs.getBigDecimal("dd_gst_percentage"), + rs.getBigDecimal("dd_gst_amount"))); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.java new file mode 100644 index 000000000..fe62527b1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationService.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.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; + +public interface SponsorDDReconciliationService { + + SponsorDDRecoBatch uploadAndReconcile(String fileName, String fileContent); + + CommandProcessingResult resolveMismatch(Long recordId, String actionType, Long resolvedSavingsTxnId, Long resolvedCurrentTxnId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java new file mode 100644 index 000000000..3f7998fc5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReconciliationServiceImpl.java @@ -0,0 +1,361 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLog; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLogRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeaflet; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeafletRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatchRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoRecord; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoRecordRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransaction; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransactionRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SponsorDDReconciliationServiceImpl implements SponsorDDReconciliationService { + + private final PlatformSecurityContext context; + private final SponsorDDRecoBatchRepository batchRepository; + private final SponsorDDRecoRecordRepository recordRepository; + private final SponsorDDLeafletRepository leafletRepository; + private final SponsorDDTransactionRepository transactionRepository; + private final SponsorDDAuditLogRepository auditLogRepository; + private final SavingsAccountTransactionRepository savingsTransactionRepository; + private final CurrentAccountTransactionRepository currentTransactionRepository; + + @Autowired + public SponsorDDReconciliationServiceImpl( + final PlatformSecurityContext context, + final SponsorDDRecoBatchRepository batchRepository, + final SponsorDDRecoRecordRepository recordRepository, + final SponsorDDLeafletRepository leafletRepository, + final SponsorDDTransactionRepository transactionRepository, + final SponsorDDAuditLogRepository auditLogRepository, + final SavingsAccountTransactionRepository savingsTransactionRepository, + final CurrentAccountTransactionRepository currentTransactionRepository) { + this.context = context; + this.batchRepository = batchRepository; + this.recordRepository = recordRepository; + this.leafletRepository = leafletRepository; + this.transactionRepository = transactionRepository; + this.auditLogRepository = auditLogRepository; + this.savingsTransactionRepository = savingsTransactionRepository; + this.currentTransactionRepository = currentTransactionRepository; + } + + @Override + @Transactional + public SponsorDDRecoBatch uploadAndReconcile(final String fileName, final String fileContent) { + final AppUser currentUser = this.context.authenticatedUser(); + + // 1. Check file uniqueness using SHA-256 hash + final String fileHash = calculateSHA256(fileContent); + final Optional existingBatch = this.batchRepository.findByFileHash(fileHash); + if (existingBatch.isPresent()) { + throw new IllegalStateException("This clearing file has already been uploaded and processed under reference: " + + existingBatch.get().getBatchReference()); + } + + // 2. Parse CSV rows + final String[] rows = fileContent.split("\\r?\\n"); + final List parsedRecords = new ArrayList<>(); + BigDecimal totalAmount = BigDecimal.ZERO; + + final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + for (int i = 0; i < rows.length; i++) { + final String row = rows[i].trim(); + if (row.isEmpty() || i == 0 && row.toLowerCase().contains("dd_number")) { + // Skip empty lines or CSV header + continue; + } + + final String[] columns = row.split(","); + if (columns.length < 4) { + // Malformed row + continue; + } + + final String ddNumber = columns[0].trim(); + final BigDecimal clearingAmount = new BigDecimal(columns[1].trim()); + final LocalDate clearingDate = LocalDate.parse(columns[2].trim(), dateFormatter); + final String clearingRef = columns[3].trim(); + + parsedRecords.add(new ParsedRecord(ddNumber, clearingAmount, clearingDate, clearingRef)); + totalAmount = totalAmount.add(clearingAmount); + } + + // 3. Create batch record + final String batchRef = "RECO-" + LocalDate.now().toString().replace("-", "") + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + SponsorDDRecoBatch batch = new SponsorDDRecoBatch( + batchRef, + fileName, + fileHash, + "PROCESSING", + parsedRecords.size(), + totalAmount, + 0, + 0, + currentUser, + LocalDateTime.now(), + null, + null + ); + batch = this.batchRepository.saveAndFlush(batch); + + int successCount = 0; + int mismatchCount = 0; + + final List recoRecords = new ArrayList<>(); + + // 4. Run matching rules + for (final ParsedRecord pr : parsedRecords) { + final Optional optLeaflet = this.leafletRepository.findByDdNumber(pr.ddNumber); + + String matchStatus = "MATCHED"; + SponsorDDLeaflet leaflet = null; + String remarks = null; + + if (optLeaflet.isPresent()) { + leaflet = optLeaflet.get(); + + if ("CLEARED".equals(leaflet.getStatus())) { + matchStatus = "DUPLICATE_CLEARING"; + remarks = "Instrument was already cleared on " + leaflet.getClearedDate(); + mismatchCount++; + } else if (!"ISSUED".equals(leaflet.getStatus())) { + matchStatus = "UNADVISED_CLEARING"; + remarks = "Leaflet status is " + leaflet.getStatus() + " instead of ISSUED"; + mismatchCount++; + } else if (leaflet.getFaceValue().compareTo(pr.clearingAmount) != 0) { + matchStatus = "AMOUNT_MISMATCH"; + remarks = "Amount mismatch: expected " + leaflet.getFaceValue() + ", cleared " + pr.clearingAmount; + mismatchCount++; + } else { + // Rule 1: Exact Match (Success) + successCount++; + leaflet.setStatus("CLEARED"); + leaflet.setClearedDate(pr.clearingDate); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now()); + this.leafletRepository.save(leaflet); + + // Create clearing transaction mapping + final SponsorDDTransaction ddTxn = new SponsorDDTransaction( + leaflet, + null, + null, + null, + "CLEARING", + pr.clearingAmount, + LocalDateTime.now() + ); + this.transactionRepository.save(ddTxn); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CLEARING_POSTED", + "ISSUED", + "CLEARED", + currentUser, + "127.0.0.1", + null, + "Sponsor clearing scroll auto-match. Ref: " + pr.clearingRef, + LocalDateTime.now() + ); + this.auditLogRepository.save(auditLog); + } + } else { + // Rule 3: Unadvised / Not found + matchStatus = "UNADVISED_CLEARING"; + remarks = "DD number not found in internal leaflet registry"; + mismatchCount++; + } + + final SponsorDDRecoRecord record = new SponsorDDRecoRecord( + batch, + pr.ddNumber, + pr.clearingAmount, + pr.clearingDate, + pr.clearingRef, + matchStatus, + leaflet, + remarks, + null, + null + ); + recoRecords.add(record); + } + + this.recordRepository.saveAllAndFlush(recoRecords); + + // 5. Update batch totals + batch.setSuccessCount(successCount); + batch.setMismatchCount(mismatchCount); + batch.setStatus(mismatchCount > 0 ? "PARTIALLY_RECONCILED" : "RECONCILED"); + batch.setLastModifiedBy(currentUser); + batch.setLastModifiedDate(LocalDateTime.now()); + this.batchRepository.save(batch); + + return batch; + } + + @Override + @Transactional + public CommandProcessingResult resolveMismatch(final Long recordId, final String actionType, + final Long resolvedSavingsTxnId, final Long resolvedCurrentTxnId) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDRecoRecord record = this.recordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("Reconciliation record not found.")); + + if (!"AMOUNT_MISMATCH".equals(record.getMatchStatus()) && !"UNADVISED_CLEARING".equals(record.getMatchStatus())) { + throw new IllegalStateException("Only exception records can be resolved. Current status: " + record.getMatchStatus()); + } + + final SponsorDDRecoBatch batch = record.getBatch(); + + SavingsAccountTransaction savingsTxn = null; + if (resolvedSavingsTxnId != null) { + savingsTxn = this.savingsTransactionRepository.findById(resolvedSavingsTxnId) + .orElseThrow(() -> new IllegalArgumentException("Resolved savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (resolvedCurrentTxnId != null) { + currentTxn = this.currentTransactionRepository.findById(resolvedCurrentTxnId) + .orElseThrow(() -> new IllegalArgumentException("Resolved current account transaction not found.")); + } + + if ("FORCE_MATCH".equals(actionType)) { + final SponsorDDLeaflet leaflet = record.getMatchedLeaflet(); + if (leaflet != null) { + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("CLEARED"); + leaflet.setClearedDate(record.getClearingDate()); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now()); + this.leafletRepository.saveAndFlush(leaflet); + + // Create transaction link + final SponsorDDTransaction ddTxn = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + null, + "CLEARING", + record.getClearingAmount(), + LocalDateTime.now() + ); + this.transactionRepository.save(ddTxn); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CLEARING_POSTED", + preStatus, + "CLEARED", + currentUser, + "127.0.0.1", + null, + "Manual reconciliation override (Force Match) approved by checker.", + LocalDateTime.now() + ); + this.auditLogRepository.save(auditLog); + } + + record.setMatchStatus("RESOLVED"); + record.setRemarks("Manually resolved by force-match override."); + record.setResolvedBy(currentUser); + record.setResolvedDate(LocalDateTime.now()); + this.recordRepository.save(record); + + // Update batch counts + batch.setSuccessCount(batch.getSuccessCount() + 1); + batch.setMismatchCount(batch.getMismatchCount() - 1); + if (batch.getMismatchCount() == 0) { + batch.setStatus("RECONCILED"); + } + batch.setLastModifiedBy(currentUser); + batch.setLastModifiedDate(LocalDateTime.now()); + this.batchRepository.save(batch); + } + + return new CommandProcessingResultBuilder() + .withEntityId(record.getId()) + .build(); + } + + private String calculateSHA256(final String text) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (final byte b : hash) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available.", e); + } + } + + private static final class ParsedRecord { + private final String ddNumber; + private final BigDecimal clearingAmount; + private final LocalDate clearingDate; + private final String clearingRef; + + public ParsedRecord(final String ddNumber, final BigDecimal clearingAmount, final LocalDate clearingDate, final String clearingRef) { + this.ddNumber = ddNumber; + this.clearingAmount = clearingAmount; + this.clearingDate = clearingDate; + this.clearingRef = clearingRef; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java new file mode 100644 index 000000000..618645f92 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformService.java @@ -0,0 +1,40 @@ +/** + * 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.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface SponsorDDReprintRequestWritePlatformService { + + /** + * Records a print of the given leaflet. The first print of a leaflet is always allowed. Any + * print after that requires a previously APPROVED, not-yet-consumed reprint request - if one + * exists it is consumed by this call; otherwise this throws, and the caller should submit a + * reprint request instead via {@link #requestReprint}. + */ + CommandProcessingResult recordPrint(Long leafletId); + + /** Clerk (maker) step: requests permission to print an already-printed leaflet again. */ + CommandProcessingResult requestReprint(Long leafletId, String reason); + + /** Manager (checker) step: approves a pending reprint request, allowing one further print. */ + CommandProcessingResult approveReprintRequest(Long reprintRequestId); + + CommandProcessingResult rejectReprintRequest(Long reprintRequestId, String rejectionReason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..478614383 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReprintRequestWritePlatformServiceImpl.java @@ -0,0 +1,195 @@ +/** + * 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.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SponsorDDReprintRequestWritePlatformServiceImpl implements SponsorDDReprintRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Override + @Transactional + public CommandProcessingResult recordPrint(final Long leafletId) { + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException("Only an issued Demand Draft can be printed. Current status: " + leaflet.get("status")); + } + final int printCount = ((Number) leaflet.get("print_count")).intValue(); + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + + if (printCount == 0) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET print_count = print_count + 1, last_printed_date = ? WHERE id = ?", now, leafletId); + return new CommandProcessingResultBuilder().withEntityId(leafletId).build(); + } + + final List> approved = this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'APPROVED' AND consumed = false " + + "ORDER BY id DESC LIMIT 1", leafletId); + if (approved.isEmpty()) { + throw new IllegalStateException( + "This Demand Draft has already been printed. Submit a reprint request for manager approval before printing again."); + } + final long reprintRequestId = ((Number) approved.get(0).get("id")).longValue(); + this.jdbcTemplate.update("UPDATE m_sponsor_dd_reprint_request SET consumed = true WHERE id = ?", reprintRequestId); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET print_count = print_count + 1, last_printed_date = ? WHERE id = ?", now, leafletId); + return new CommandProcessingResultBuilder().withEntityId(leafletId).withSubEntityId(reprintRequestId).build(); + } + + @Override + @Transactional + public CommandProcessingResult requestReprint(final Long leafletId, final String reason) { + final AppUser currentUser = this.context.authenticatedUser(); + final Map leaflet = findLeafletOrThrow(leafletId); + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException("Only an issued Demand Draft can be reprinted. Current status: " + leaflet.get("status")); + } + final int printCount = ((Number) leaflet.get("print_count")).intValue(); + if (printCount == 0) { + throw new IllegalStateException("This Demand Draft has not been printed yet - use Print, not Request Reprint."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required to request a reprint."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId).isEmpty()) { + throw new IllegalStateException( + "A reprint request is already pending for this Demand Draft. Wait for it to be approved or rejected."); + } + if (!this.jdbcTemplate.queryForList( + "SELECT id FROM m_sponsor_dd_reprint_request WHERE leaflet_id = ? AND status = 'APPROVED' AND consumed = false", + leafletId).isEmpty()) { + throw new IllegalStateException("A reprint has already been approved for this Demand Draft - use Print to consume it."); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_reprint_request (leaflet_id, reason, status, maker_id, created_date) " + + "VALUES (?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setString(2, reason); + ps.setString(3, STATUS_PENDING); + ps.setLong(4, currentUser.getId()); + ps.setObject(5, now); + return ps; + }, kh); + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + @Override + @Transactional + public CommandProcessingResult approveReprintRequest(final Long reprintRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findReprintRequestOrThrow(reprintRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Reprint request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own reprint request."); + } + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_reprint_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, reprintRequestId); + return new CommandProcessingResultBuilder().withEntityId(reprintRequestId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectReprintRequest(final Long reprintRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findReprintRequestOrThrow(reprintRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Reprint request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own reprint request."); + } + + final LocalDateTime now = LocalDateTime.now(java.time.Clock.systemDefaultZone()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_reprint_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, reprintRequestId); + return new CommandProcessingResultBuilder().withEntityId(reprintRequestId).build(); + } + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, print_count FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Map findReprintRequestOrThrow(final Long reprintRequestId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT * FROM m_sponsor_dd_reprint_request WHERE id = ?", reprintRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD reprint request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) return true; + return user.getRoles().stream() + .map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject reprint requests."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java new file mode 100644 index 000000000..e294e16b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformService.java @@ -0,0 +1,43 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface SponsorDDWritePlatformService { + + CommandProcessingResult registerInventory(Long sponsorBankId, Long branchId, Long seriesStart, Long seriesEnd, LocalDate receivedDate); + + /** + * clientId may be null for a walk-in purchaser who isn't a bank client (cash payment) — in that + * case walkinPurchaserName/Address/AadhaarMasked identify the purchaser instead. + */ + CommandProcessingResult issueDemandDraft(Long leafletId, Long clientId, String walkinPurchaserName, + String walkinPurchaserAddress, String walkinPurchaserAadhaarMasked, String beneficiaryName, BigDecimal faceValue, + Long issuingBranchId, LocalDate issuedDate, Long savingsTransactionId, Long currentTransactionId, Long journalEntryId, + String payableAtBranch, String payableAtCity); + + CommandProcessingResult cancelDemandDraft(Long leafletId, String remarks, Long refundSavingsTransactionId, Long refundCurrentTransactionId); + + CommandProcessingResult stopPaymentDemandDraft(Long leafletId, String remarks); + + CommandProcessingResult sendAdvice(Long sponsorBankId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java new file mode 100644 index 000000000..d7c85c5ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java @@ -0,0 +1,403 @@ +/** + * 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.sponsordd.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLog; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDAuditLogRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDInventory; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDInventoryRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeaflet; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDLeafletRepository; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransaction; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDTransactionRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SponsorDDWritePlatformServiceImpl implements SponsorDDWritePlatformService { + + private final PlatformSecurityContext context; + private final SponsorDDInventoryRepository inventoryRepository; + private final SponsorDDLeafletRepository leafletRepository; + private final SponsorDDTransactionRepository transactionRepository; + private final SponsorDDAuditLogRepository auditLogRepository; + private final SponsorBankRepository sponsorBankRepository; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final SavingsAccountTransactionRepository savingsTransactionRepository; + private final CurrentAccountTransactionRepository currentTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final JdbcTemplate jdbcTemplate; + + @Autowired + public SponsorDDWritePlatformServiceImpl( + final PlatformSecurityContext context, + final SponsorDDInventoryRepository inventoryRepository, + final SponsorDDLeafletRepository leafletRepository, + final SponsorDDTransactionRepository transactionRepository, + final SponsorDDAuditLogRepository auditLogRepository, + final SponsorBankRepository sponsorBankRepository, + final OfficeRepositoryWrapper officeRepositoryWrapper, + final ClientRepositoryWrapper clientRepositoryWrapper, + final SavingsAccountTransactionRepository savingsTransactionRepository, + final CurrentAccountTransactionRepository currentTransactionRepository, + final JournalEntryRepository journalEntryRepository, + final JdbcTemplate jdbcTemplate) { + this.context = context; + this.inventoryRepository = inventoryRepository; + this.leafletRepository = leafletRepository; + this.transactionRepository = transactionRepository; + this.auditLogRepository = auditLogRepository; + this.sponsorBankRepository = sponsorBankRepository; + this.officeRepositoryWrapper = officeRepositoryWrapper; + this.clientRepositoryWrapper = clientRepositoryWrapper; + this.savingsTransactionRepository = savingsTransactionRepository; + this.currentTransactionRepository = currentTransactionRepository; + this.journalEntryRepository = journalEntryRepository; + this.jdbcTemplate = jdbcTemplate; + } + + @Override + @Transactional + public CommandProcessingResult registerInventory(final Long sponsorBankId, final Long branchId, final Long seriesStart, + final Long seriesEnd, final LocalDate receivedDate) { + + final AppUser currentUser = this.context.authenticatedUser(); + + if (seriesStart > seriesEnd) { + throw new IllegalArgumentException("Series start number must be less than or equal to series end number."); + } + + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor Bank not found with ID: " + sponsorBankId)); + final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(branchId); + final int totalLeaves = (int) (seriesEnd - seriesStart) + 1; + + final SponsorDDInventory inventory = new SponsorDDInventory( + sponsorBank, + branch, + seriesStart, + seriesEnd, + totalLeaves, + receivedDate, + "ACTIVE", + currentUser, + LocalDateTime.now(), + null, + null + ); + + this.inventoryRepository.saveAndFlush(inventory); + + // Pre-create leaflets in bulk + final List leaflets = new ArrayList<>(); + for (long currentVal = seriesStart; currentVal <= seriesEnd; currentVal++) { + final String ddNumber = String.valueOf(currentVal); + final SponsorDDLeaflet leaflet = new SponsorDDLeaflet( + inventory, + ddNumber, + "AVAILABLE", + BigDecimal.ZERO, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "NOT_SENT", + null, + 1, + currentUser, + LocalDateTime.now(), + null, + null + ); + leaflets.add(leaflet); + } + this.leafletRepository.saveAllAndFlush(leaflets); + + // Audit the creation of each leaflet + final List auditLogs = new ArrayList<>(); + for (final SponsorDDLeaflet leaflet : leaflets) { + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "STOCK_RECEIPT", + null, + "AVAILABLE", + currentUser, + "127.0.0.1", + null, + "Draft leaflet registered into inventory.", + LocalDateTime.now() + ); + auditLogs.add(auditLog); + } + this.auditLogRepository.saveAll(auditLogs); + + return new CommandProcessingResultBuilder() + .withEntityId(inventory.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult issueDemandDraft(final Long leafletId, final Long clientId, final String walkinPurchaserName, + final String walkinPurchaserAddress, final String walkinPurchaserAadhaarMasked, final String beneficiaryName, + final BigDecimal faceValue, final Long issuingBranchId, final LocalDate issuedDate, + final Long savingsTransactionId, final Long currentTransactionId, final Long journalEntryId, + final String payableAtBranch, final String payableAtCity) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"AVAILABLE".equals(leaflet.getStatus())) { + throw new IllegalStateException("Demand Draft leaflet is not available. Current status: " + leaflet.getStatus()); + } + + final Client client = clientId != null ? this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId) : null; + final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(issuingBranchId); + + // Find linked financial items + SavingsAccountTransaction savingsTxn = null; + if (savingsTransactionId != null) { + savingsTxn = this.savingsTransactionRepository.findById(savingsTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (currentTransactionId != null) { + currentTxn = this.currentTransactionRepository.findById(currentTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Current account transaction not found.")); + } + + JournalEntry journalEntry = null; + if (journalEntryId != null) { + journalEntry = this.journalEntryRepository.findById(journalEntryId) + .orElseThrow(() -> new IllegalArgumentException("Journal Entry not found.")); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("ISSUED"); + leaflet.setFaceValue(faceValue); + leaflet.setIssuedToClient(client); + leaflet.setWalkinPurchaserName(walkinPurchaserName); + leaflet.setWalkinPurchaserAddress(walkinPurchaserAddress); + leaflet.setWalkinPurchaserAadhaarMasked(walkinPurchaserAadhaarMasked); + leaflet.setBeneficiaryName(beneficiaryName); + leaflet.setPayableAtBranch(payableAtBranch); + leaflet.setPayableAtCity(payableAtCity); + leaflet.setIssuingBranch(branch); + leaflet.setIssuedDate(issuedDate); + leaflet.setAdviceStatus("NOT_SENT"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + journalEntry, + "ISSUANCE", + faceValue, + LocalDateTime.now() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "ISSUED", + preStatus, + "ISSUED", + currentUser, + "127.0.0.1", + null, + "Demand draft issued to customer.", + LocalDateTime.now() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult cancelDemandDraft(final Long leafletId, final String remarks, + final Long refundSavingsTransactionId, final Long refundCurrentTransactionId) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"ISSUED".equals(leaflet.getStatus())) { + throw new IllegalStateException("Only ISSUED Demand Drafts can be cancelled. Current status: " + leaflet.getStatus()); + } + + SavingsAccountTransaction savingsTxn = null; + if (refundSavingsTransactionId != null) { + savingsTxn = this.savingsTransactionRepository.findById(refundSavingsTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Refund savings transaction not found.")); + } + + CurrentAccountTransaction currentTxn = null; + if (refundCurrentTransactionId != null) { + currentTxn = this.currentTransactionRepository.findById(refundCurrentTransactionId) + .orElseThrow(() -> new IllegalArgumentException("Refund current account transaction not found.")); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("CANCELLED"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link refund transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + savingsTxn, + currentTxn, + null, + "CANCELLATION", + leaflet.getFaceValue(), + LocalDateTime.now() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "CANCELLED", + preStatus, + "CANCELLED", + currentUser, + "127.0.0.1", + null, + remarks != null ? remarks : "Demand draft cancelled and refunded.", + LocalDateTime.now() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult stopPaymentDemandDraft(final Long leafletId, final String remarks) { + + final AppUser currentUser = this.context.authenticatedUser(); + final SponsorDDLeaflet leaflet = this.leafletRepository.findById(leafletId) + .orElseThrow(() -> new IllegalArgumentException("Demand Draft leaflet not found.")); + + if (!"ISSUED".equals(leaflet.getStatus())) { + throw new IllegalStateException("Only ISSUED Demand Drafts can be stopped. Current status: " + leaflet.getStatus()); + } + + final String preStatus = leaflet.getStatus(); + leaflet.setStatus("STOPPED"); + leaflet.setLastModifiedBy(currentUser); + leaflet.setLastModifiedDate(LocalDateTime.now()); + + this.leafletRepository.saveAndFlush(leaflet); + + // Link stop transaction + final SponsorDDTransaction ddTransaction = new SponsorDDTransaction( + leaflet, + null, + null, + null, + "STOP_PAYMENT", + leaflet.getFaceValue(), + LocalDateTime.now() + ); + this.transactionRepository.save(ddTransaction); + + // Audit Log + final SponsorDDAuditLog auditLog = new SponsorDDAuditLog( + leaflet, + "STOP_PAYMENT", + preStatus, + "STOPPED", + currentUser, + "127.0.0.1", + null, + remarks != null ? remarks : "Stop payment instruction processed.", + LocalDateTime.now() + ); + this.auditLogRepository.save(auditLog); + + return new CommandProcessingResultBuilder() + .withEntityId(leaflet.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult sendAdvice(final Long sponsorBankId) { + final AppUser currentUser = this.context.authenticatedUser(); + final LocalDateTime now = LocalDateTime.now(); + + // Update m_sponsor_dd_leaflet for the sponsor bank where status is ISSUED and advice_status is NOT_SENT + final String sql = "UPDATE m_sponsor_dd_leaflet leaf " + + "SET advice_status = 'SENT', advice_sent_date = ?, lastmodifiedby_id = ?, lastmodified_date = ? " + + "WHERE leaf.status = 'ISSUED' AND leaf.advice_status = 'NOT_SENT' " + + "AND leaf.inventory_id IN (SELECT id FROM m_sponsor_dd_inventory WHERE sponsor_bank_id = ?)"; + + this.jdbcTemplate.update(sql, now, currentUser.getId(), now, sponsorBankId); + + return new CommandProcessingResultBuilder() + .withEntityId(sponsorBankId) + .build(); + } +} 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 f59a3be7a..cd113a4f9 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 @@ -435,4 +435,29 @@ + + + + + + + + + + + + + + + + + + + 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-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml new file mode 100644 index 000000000..8a21ba3fa --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_create_sponsor_bank_dd_tables.xml @@ -0,0 +1,1290 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DELETE FROM m_sponsor_dd_audit_log; + + + + + + + + + DELETE FROM m_sponsor_dd_transaction; + + + + + + + + + DELETE FROM m_sponsor_dd_reco_record; + + + + + + + + + DELETE FROM m_sponsor_dd_reco_batch; + + + + + + + + + DELETE FROM m_sponsor_dd_leaflet; + + + + + + + + + DELETE FROM m_sponsor_dd_inventory; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml new file mode 100644 index 000000000..b93c18cd5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_create_sponsor_dd_issue_request_tables.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml new file mode 100644 index 000000000..09ae13f0c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml @@ -0,0 +1,73 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.3' + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.3' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.3' AND hierarchy = '.'; + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping m join m_payment_type pt on pt.id = m.payment_type_id where pt.value = 'SBI' + + + + INSERT INTO m_sponsor_dd_gl_mapping (payment_type_id, dd_payable_gl_account_id, created_date) + SELECT pt.id, gl.id, CURRENT_TIMESTAMP + FROM m_payment_type pt, acc_gl_account gl + WHERE pt.value = 'SBI' AND gl.gl_code = '9.3'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml new file mode 100644 index 000000000..d18351925 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_add_sponsor_dd_walkin_cash_support.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml new file mode 100644 index 000000000..c7494f36a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml @@ -0,0 +1,77 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '10.3' + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '10.3' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '10.3' AND hierarchy = '.'; + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where cash_gl_account_id is null or charges_income_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_gl_account_id = (select id from acc_gl_account where gl_code = '2'), + charges_income_gl_account_id = (select id from acc_gl_account where gl_code = '10.3') + WHERE cash_gl_account_id is null or charges_income_gl_account_id is null; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml new file mode 100644 index 000000000..71854b537 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_replace_payment_type_with_sponsor_bank.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + DROP TABLE IF EXISTS m_sponsor_bank CASCADE; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- Insert unique sponsor banks from payment types into m_sponsor_bank + INSERT INTO m_sponsor_bank (name, description, createdby_id, created_date) + SELECT DISTINCT pt.value, pt.description, 1, CURRENT_TIMESTAMP + FROM m_payment_type pt + WHERE (pt.description = 'Sponsor Bank' + OR pt.id IN (SELECT DISTINCT payment_type_id FROM m_sponsor_dd_inventory)) + AND pt.value NOT IN (SELECT name FROM m_sponsor_bank); + + + + + + + + + + + + + + + + + + + + + + + + -- Update sponsor_bank_id based on name matching from m_payment_type + UPDATE m_sponsor_dd_inventory + SET sponsor_bank_id = ( + SELECT sb.id + FROM m_sponsor_bank sb + JOIN m_payment_type pt ON pt.value = sb.name + WHERE pt.id = m_sponsor_dd_inventory.payment_type_id + LIMIT 1 + ) + WHERE sponsor_bank_id IS NULL; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml new file mode 100644 index 000000000..a762a676a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_add_sponsor_dd_cash_receipt_suspense.xml @@ -0,0 +1,119 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.4' + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.4' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.4' AND hierarchy = '.'; + + + + + + + + + + + + + + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where cash_suspense_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_suspense_gl_account_id = (select id from acc_gl_account where gl_code = '9.4') + WHERE cash_suspense_gl_account_id is null; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml new file mode 100644 index 000000000..fe0d6682f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0351_add_sponsor_dd_reprint_approval.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml new file mode 100644 index 000000000..57004ed69 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0352_add_sponsor_dd_payable_at.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml new file mode 100644 index 000000000..02c79e220 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_add_sponsor_dd_gst_on_charges.xml @@ -0,0 +1,109 @@ + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' and hierarchy = '.' + + + UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.5' AND hierarchy = '.'; + + + + + + + + + + + + + + + + + + + + + + + + + + select count(*) from m_sponsor_dd_gl_mapping where gst_payable_gl_account_id is null + + + + + UPDATE m_sponsor_dd_gl_mapping + SET gst_payable_gl_account_id = (select id from acc_gl_account where gl_code = '9.5') + WHERE gst_payable_gl_account_id is null; + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml new file mode 100644 index 000000000..c7e6f02ec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_add_sponsor_bank_identification_codes.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml new file mode 100644 index 000000000..8af9908fd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_add_sponsor_dd_cashier_deposit_link.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + select count(*) from acc_gl_account where gl_code = '1' + + + UPDATE m_sponsor_dd_gl_mapping + SET cash_gl_account_id = (select id from acc_gl_account where gl_code = '1') + WHERE cash_gl_account_id = (select id from acc_gl_account where gl_code = '2'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml new file mode 100644 index 000000000..6303e9d4e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml new file mode 100644 index 000000000..ec10d954f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml new file mode 100644 index 000000000..9a838f0b9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml new file mode 100644 index 000000000..4504274ff --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0359_reclassify_dd_gst_payable_as_income.xml @@ -0,0 +1,41 @@ + + + + + + + + select count(*) from acc_gl_account where gl_code = '9.5' and classification_enum = 2 + + + UPDATE acc_gl_account + SET classification_enum = 4, parent_id = 25 + WHERE gl_code = '9.5'; + + + + 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 4488167b9..36013d462 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 @@ -1,243 +1,318 @@ -/** - * 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 java.math.BigDecimal; -import java.time.LocalDate; -import java.time.MonthDay; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.portfolio.charge.domain.Charge; -import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; -import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsAccount; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; -import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; -import org.springframework.stereotype.Service; - -/** - * Reads account's facility config (Yes/No per facility) and auto-applies - * the corresponding charge from m_facility_charge_mapping. - * No manual charge selection needed — fully automatic. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class FacilityChargeAutoApplyService { - - private final FacilityChargeMappingRepository mappingRepository; - private final SavingsAccountChargeRepository savingsAccountChargeRepository; - private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; - - public void applyFacilityChargesOnActivation(final SavingsAccount account) { - // Use account-level facility config — this reflects what was selected at account opening time - final SavingsAccountFacilityConfig config = account.getFacilityConfig(); - - if (config == null) { - // Fallback: use product-level config if account config not set - applyFromProductConfig(account); - return; - } - - final List chargesToApply = new ArrayList<>(); - - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted - // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having - // the facility enabled. - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); - - if (!chargesToApply.isEmpty()) { - savingsAccountChargeRepository.saveAll(chargesToApply); - log.info("Auto-applied {} facility charge(s) to savings account id={}", - chargesToApply.size(), account.getId()); - } - } - - /** - * Collects all unpaid SavingsActivation charges on the account. - * Called after facility charges are saved and flushed, so amounts are properly initialized. - */ - public List collectActivationCharges(final SavingsAccount account) { - final List transactions = new ArrayList<>(); - final LocalDate activationDate = account.getActivationDate(); - - for (final SavingsAccountCharge charge : account.charges()) { - if (!charge.isSavingsActivation()) continue; - if (charge.isPaid() || charge.isWaived()) continue; - - final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final org.apache.fineract.organisation.monetary.domain.Money amountMoney = - org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) { - transactions.add(txn); - log.info("Collected activation charge '{}' ₹{} from savings account id={}", - charge.getCharge().getName(), amount, account.getId()); - } - } catch (Exception e) { - log.warn("Could not collect activation charge '{}' from savings account id={}: {}", - charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage()); - } - } - - if (!transactions.isEmpty()) { - savingsAccountTransactionRepository.saveAll(transactions); - account.recalculateBalancesAfterFacilityCharges(activationDate); - } - return transactions; - } - - private void autoCollectSavingsActivationCharges(final SavingsAccount account, - final List charges) { - final LocalDate activationDate = account.getActivationDate(); - final List transactions = new ArrayList<>(); - - for (final SavingsAccountCharge charge : charges) { - if (!charge.isSavingsActivation()) continue; - final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; - - try { - final Money amountMoney = Money.of(account.getCurrency(), amount); - final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); - if (txn != null) transactions.add(txn); - log.info("Auto-collected facility charge '{}' ₹{} from account id={}", - charge.getCharge().getName(), amount, account.getId()); - } catch (Exception e) { - log.warn("Could not auto-collect facility charge '{}' from account id={}: {}", - charge.getCharge().getName(), account.getId(), e.getMessage()); - } - } - - if (!transactions.isEmpty()) { - savingsAccountTransactionRepository.saveAll(transactions); - account.recalculateBalancesAfterFacilityCharges(activationDate); - } - } - - private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, - final String facilityType, final List collector) { - - if (!facilityEnabled) return; - - // Skip if this charge is already applied to the account - boolean alreadyApplied = account.charges().stream() - .anyMatch(c -> c.getCharge() != null - && c.getCharge().getName() != null - && c.getCharge().getName().equals(getChargeName(facilityType))); - if (alreadyApplied) return; - - 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, // chargeTime derived from Charge definition - null, // chargeCalculation derived from Charge definition - resolveDueDate(charge), - true, - feeOnMonthDay, - charge.getFeeInterval()); - - accountCharge.update(account); - account.charges().add(accountCharge); - collector.add(accountCharge); - }); - } - - private MonthDay resolveFeeOnMonthDay(final Charge charge) { - if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay(); - if (charge.isMonthlyFee() || charge.isAnnualFee()) { - // Default: 1st of the month - return MonthDay.of(LocalDate.now().plusMonths(1).getMonth(), 1); - } - return null; - } - - private LocalDate resolveDueDate(final Charge charge) { - if (charge.isMonthlyFee()) { - return LocalDate.now().plusMonths(1).withDayOfMonth(1); - } else if (charge.isAnnualFee()) { - return LocalDate.now().plusYears(1); - } - return null; - } - - private String getChargeName(final String facilityType) { - return mappingRepository.findByFacilityType(facilityType) - .map(FacilityChargeMapping::getChargeName) - .orElse(""); - } - - private void applyFromProductConfig(final SavingsAccount account) { - final SavingsProductFacilityConfig config = account.savingsProduct() != null - ? account.savingsProduct().getFacilityConfig() : null; - if (config == null) return; - - final List chargesToApply = new ArrayList<>(); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); - // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted - // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having - // the facility enabled. - applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); - applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); - if (!chargesToApply.isEmpty()) { - savingsAccountChargeRepository.saveAll(chargesToApply); - } - } -} +/** + * 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 java.math.BigDecimal; +import java.time.LocalDate; +import java.time.MonthDay; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargeRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; +import org.springframework.stereotype.Service; + +/** + * Reads account's facility config (Yes/No per facility) and auto-applies + * the corresponding charge from m_facility_charge_mapping. + * No manual charge selection needed — fully automatic. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FacilityChargeAutoApplyService { + + private final FacilityChargeMappingRepository mappingRepository; + private final SavingsAccountChargeRepository savingsAccountChargeRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + + public void applyFacilityChargesOnActivation(final SavingsAccount account) { + // Use account-level facility config — this reflects what was selected at account opening time + final SavingsAccountFacilityConfig config = account.getFacilityConfig(); + + if (config == null) { + // Fallback: use product-level config if account config not set + applyFromProductConfig(account); + return; + } + + final List chargesToApply = new ArrayList<>(); + + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted + // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having + // the facility enabled. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); + + if (!chargesToApply.isEmpty()) { + savingsAccountChargeRepository.saveAll(chargesToApply); + log.info("Auto-applied {} facility charge(s) to savings account id={}", + chargesToApply.size(), account.getId()); + } + } + + /** + * 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. + */ + public List collectActivationCharges(final SavingsAccount account) { + final List transactions = new ArrayList<>(); + final LocalDate activationDate = account.getActivationDate(); + + for (final SavingsAccountCharge charge : account.charges()) { + if (!charge.isSavingsActivation()) continue; + if (charge.isPaid() || charge.isWaived()) continue; + + final BigDecimal amount = charge.getAmount(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + try { + final org.apache.fineract.organisation.monetary.domain.Money amountMoney = + org.apache.fineract.organisation.monetary.domain.Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); + if (txn != null) { + transactions.add(txn); + log.info("Collected activation charge '{}' ₹{} from savings account id={}", + charge.getCharge().getName(), amount, account.getId()); + } + } catch (Exception e) { + log.warn("Could not collect activation charge '{}' from savings account id={}: {}", + charge.getCharge() != null ? charge.getCharge().getName() : "?", account.getId(), e.getMessage()); + } + } + + if (!transactions.isEmpty()) { + savingsAccountTransactionRepository.saveAll(transactions); + account.recalculateBalancesAfterFacilityCharges(activationDate); + } + return transactions; + } + + private void autoCollectSavingsActivationCharges(final SavingsAccount account, + final List charges) { + final LocalDate activationDate = account.getActivationDate(); + final List transactions = new ArrayList<>(); + + for (final SavingsAccountCharge charge : charges) { + if (!charge.isSavingsActivation()) continue; + final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue; + + try { + final Money amountMoney = Money.of(account.getCurrency(), amount); + final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, activationDate, false, null); + if (txn != null) transactions.add(txn); + log.info("Auto-collected facility charge '{}' ₹{} from account id={}", + charge.getCharge().getName(), amount, account.getId()); + } catch (Exception e) { + log.warn("Could not auto-collect facility charge '{}' from account id={}: {}", + charge.getCharge().getName(), account.getId(), e.getMessage()); + } + } + + if (!transactions.isEmpty()) { + savingsAccountTransactionRepository.saveAll(transactions); + account.recalculateBalancesAfterFacilityCharges(activationDate); + } + } + + private void applyIfEnabled(final SavingsAccount account, final boolean facilityEnabled, + final String facilityType, final List collector) { + + if (!facilityEnabled) return; + + // Skip if this charge is already applied to the account + boolean alreadyApplied = account.charges().stream() + .anyMatch(c -> c.getCharge() != null + && c.getCharge().getName() != null + && c.getCharge().getName().equals(getChargeName(facilityType))); + if (alreadyApplied) return; + + 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, // chargeTime derived from Charge definition + null, // chargeCalculation derived from Charge definition + resolveDueDate(charge), + true, + feeOnMonthDay, + charge.getFeeInterval()); + + accountCharge.update(account); + account.charges().add(accountCharge); + collector.add(accountCharge); + }); + } + + private MonthDay resolveFeeOnMonthDay(final Charge charge) { + if (charge.getFeeOnMonthDay() != null) return charge.getFeeOnMonthDay(); + if (charge.isMonthlyFee() || charge.isAnnualFee()) { + // Default: 1st of the month + return MonthDay.of(LocalDate.now().plusMonths(1).getMonth(), 1); + } + return null; + } + + private LocalDate resolveDueDate(final Charge charge) { + if (charge.isMonthlyFee()) { + return LocalDate.now().plusMonths(1).withDayOfMonth(1); + } else if (charge.isAnnualFee()) { + return LocalDate.now().plusYears(1); + } + return null; + } + + private String getChargeName(final String facilityType) { + return mappingRepository.findByFacilityType(facilityType) + .map(FacilityChargeMapping::getChargeName) + .orElse(""); + } + + private void applyFromProductConfig(final SavingsAccount account) { + final SavingsProductFacilityConfig config = account.savingsProduct() != null + ? account.savingsProduct().getFacilityConfig() : null; + if (config == null) return; + + final List chargesToApply = new ArrayList<>(); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmFacility()), "ATM_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getAtmWithdrawalFacility()), "ATM_WITHDRAWAL", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getUpiFacility()), "UPI_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getPosTransactionsFacility()), "POS_TRANSACTIONS", chargesToApply); + // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted + // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having + // the facility enabled. + applyIfEnabled(account, Boolean.TRUE.equals(config.getImpsFacility()), "IMPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getSmsAlerts()), "SMS_ALERTS", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBbpsFacility()), "BBPS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInsuranceCover()), "INSURANCE_COVER", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getBepgFacility()), "BEPG_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply); + applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply); + if (!chargesToApply.isEmpty()) { + savingsAccountChargeRepository.saveAll(chargesToApply); + } + } +} 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' + + + + + + + + + +