Merge remote-tracking branch 'nexora/uate' into merge-uate-into-dev
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s

# Conflicts:
#	.gitignore
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
#	fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java
This commit is contained in:
2026-07-23 17:36:10 +05:30
96 changed files with 11486 additions and 1304 deletions
+39 -39
View File
@@ -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/
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* Read-side view of a single pending current account Nominee/Digital-Feature edit
* (m_current_account_pending_edit), for the Approvals -> Pending Current Account Edits screen.
*/
@Getter
@Setter
public class CurrentAccountPendingEditData implements Serializable {
private Long id;
private Long currentAccountId;
private String accountNo;
private Long makerId;
private String makerUsername;
private Long checkerId;
private String checkerUsername;
private LocalDateTime createdOn;
private LocalDateTime checkedOn;
private String status;
private String rejectReason;
private BigDecimal holdAmount;
private boolean facilityConfigSubmitted;
private boolean nomineeSubmitted;
private Map<String, Object> proposedFacilityConfig;
private Map<String, Object> proposedNominee;
public CurrentAccountPendingEditData() {}
}
@@ -0,0 +1,55 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* Read-side view of a single pending savings account Nominee/Digital-Feature edit
* (m_savings_account_pending_edit), for the Approvals -> Pending Savings Account Edits screen.
*/
@Getter
@Setter
public class SavingsAccountPendingEditData implements Serializable {
private Long id;
private Long savingsAccountId;
private String accountNo;
private Long makerId;
private String makerUsername;
private Long checkerId;
private String checkerUsername;
private LocalDateTime createdOn;
private LocalDateTime checkedOn;
private String status;
private String rejectReason;
private BigDecimal holdAmount;
private boolean facilityConfigSubmitted;
private boolean nomineeSubmitted;
private Map<String, Object> proposedFacilityConfig;
private Map<String, Object> proposedNominee;
private String depositAccountType;
public SavingsAccountPendingEditData() {}
}
@@ -99,4 +99,31 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
cfg.currentAccount = account;
return cfg;
}
/**
* Partial update: only overwrites fields present (non-null) in the given JSON, leaving all others untouched.
* Mirrors {@code SavingsAccountFacilityConfig.updateFrom(JsonObject)}. Used by the Nominee/Digital-Feature
* edit-approval flow, which only ever submits the toggles the maker actually touched.
*/
public void updateFrom(com.google.gson.JsonObject config) {
if (config == null) return;
if (config.has("atmFacility")) setAtmFacility(config.get("atmFacility").getAsBoolean());
if (config.has("atmWithdrawalFacility")) setAtmWithdrawalFacility(config.get("atmWithdrawalFacility").getAsBoolean());
if (config.has("upiFacility")) setUpiFacility(config.get("upiFacility").getAsBoolean());
if (config.has("posTransactionsFacility")) setPosTransactionsFacility(config.get("posTransactionsFacility").getAsBoolean());
if (config.has("chequeBook")) setChequeBook(config.get("chequeBook").getAsBoolean());
if (config.has("impsFacility")) setImpsFacility(config.get("impsFacility").getAsBoolean());
if (config.has("smsAlerts")) setSmsAlerts(config.get("smsAlerts").getAsBoolean());
if (config.has("bbpsFacility")) setBbpsFacility(config.get("bbpsFacility").getAsBoolean());
if (config.has("insuranceCover")) setInsuranceCover(config.get("insuranceCover").getAsBoolean());
if (config.has("bepgFacility")) setBepgFacility(config.get("bepgFacility").getAsBoolean());
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
if (config.has("mobileBanking")) setMobileBanking(config.get("mobileBanking").getAsBoolean());
}
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* This repository did not previously exist - {@link CurrentAccountFacilityConfig} rows were only ever persisted via
* cascade from {@link CurrentAccountRepository} (see {@code CurrentAccount#facilityConfig}, {@code cascade = ALL}).
* Added so the Nominee/Digital-Feature edit-approval flow can save the config row directly, mirroring
* {@code SavingsAccountFacilityConfigRepository}.
*/
@Repository
public interface CurrentAccountFacilityConfigRepository extends JpaRepository<CurrentAccountFacilityConfig, Long> {
Optional<CurrentAccountFacilityConfig> findByCurrentAccountId(Long currentAccountId);
}
@@ -0,0 +1,596 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Holds a pending edit request for an ACTIVE current account's Nominee + Digital & Account Features.
* Presence of a row here with {@code status = PENDING} (enforced via the unique {@link #pendingLockKey}) is itself
* the lock - nothing is applied to the real {@code m_current_account_facility_config} / {@code m_client_nominee}
* rows until a checker approves (see CurrentAccountEditWritePlatformServiceJpaRepositoryImpl). Mirrors
* {@code SavingsAccountPendingEdit} but additionally carries a full point-in-time "wide snapshot" of every business
* column on {@link CurrentAccount} at submission time, for audit/UI display purposes only (not used to drive
* approve/reject logic, which reads the live account).
*/
@Entity
@Table(name = "m_current_account_pending_edit")
@Getter
@Setter
public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String NOMINEE_OP_ADD = "ADD";
public static final String NOMINEE_OP_UPDATE = "UPDATE";
public static final String NOMINEE_OP_DELETE = "DELETE";
@ManyToOne
@JoinColumn(name = "current_account_id", nullable = false)
private CurrentAccount currentAccount;
@ManyToOne
@JoinColumn(name = "createdby_id", nullable = false)
private AppUser maker;
@ManyToOne
@JoinColumn(name = "lastmodifiedby_id")
private AppUser checker;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdOn;
@Column(name = "lastmodified_date")
private LocalDateTime checkedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
/** "CURRENT_ACCOUNT_EDIT:<currentAccountId>" while PENDING, NULL otherwise - the real DB-level lock. */
@Column(name = "pending_lock_key", length = 40)
private String pendingLockKey;
/** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */
@Column(name = "command_source_id")
private Long commandSourceId;
@Column(name = "hold_transaction_id")
private Long holdTransactionId;
@Column(name = "hold_amount", precision = 19, scale = 6)
private BigDecimal holdAmount;
// ── Digital & Account Features (same names as m_current_account_facility_config) ──
@Column(name = "facility_config_submitted", nullable = false)
private boolean facilityConfigSubmitted = false;
@Column(name = "atm_facility")
private Boolean atmFacility;
@Column(name = "atm_withdrawal_facility")
private Boolean atmWithdrawalFacility;
@Column(name = "upi_facility")
private Boolean upiFacility;
@Column(name = "pos_transactions_facility")
private Boolean posTransactionsFacility;
@Column(name = "cheque_book")
private Boolean chequeBook;
@Column(name = "imps_facility")
private Boolean impsFacility;
@Column(name = "sms_alerts")
private Boolean smsAlerts;
@Column(name = "bbps_facility")
private Boolean bbpsFacility;
@Column(name = "insurance_cover")
private Boolean insuranceCover;
@Column(name = "bepg_facility")
private Boolean bepgFacility;
@Column(name = "neft_facility")
private Boolean neftFacility;
@Column(name = "rtgs_facility")
private Boolean rtgsFacility;
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@Column(name = "virtual_debit_card")
private Boolean virtualDebitCard;
@Column(name = "internet_banking")
private Boolean internetBanking;
@Column(name = "mobile_banking")
private Boolean mobileBanking;
// ── Nominee change - one per request (add / update / delete) ──
@Column(name = "nominee_submitted", nullable = false)
private boolean nomineeSubmitted = false;
@Column(name = "nominee_operation", length = 10)
private String nomineeOperation;
@Column(name = "nominee_id")
private Long nomineeId;
@Column(name = "nominee_client_id")
private Long nomineeClientId;
@Column(name = "relationship_cv_id")
private Long relationshipCvId;
@Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare;
@Column(name = "nominee_is_active")
private Boolean nomineeIsActive;
@Column(name = "nominee_is_minor")
private Boolean nomineeIsMinor;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "guardian_relationship_cv_id")
private Long guardianRelationshipCvId;
@Column(name = "effective_date")
private LocalDate effectiveDate;
@Column(name = "nominee_remarks", length = 500)
private String nomineeRemarks;
// ── Wide point-in-time snapshot of every business column on CurrentAccount at submission time ──
// (audit/UI display only - approve()/reject() logic never reads these, it reads the live account)
@Column(name = "account_no", length = 20)
private String snapshotAccountNo;
@Column(name = "external_id", length = 100)
private String snapshotExternalId;
@Column(name = "client_id")
private Long snapshotClientId;
@Column(name = "product_id")
private Long snapshotProductId;
@Column(name = "office_id")
private Long snapshotOfficeId;
@Column(name = "staff_id")
private Long snapshotStaffId;
@Column(name = "status_enum")
private Integer snapshotStatusEnum;
@Column(name = "sub_status_enum")
private Integer snapshotSubStatusEnum;
@Column(name = "account_type_enum")
private Integer snapshotAccountTypeEnum;
@Column(name = "submittedon_date")
private LocalDate snapshotSubmittedOnDate;
@Column(name = "approvedon_date")
private LocalDate snapshotApprovedOnDate;
@Column(name = "activatedon_date")
private LocalDate snapshotActivatedOnDate;
@Column(name = "rejectedon_date")
private LocalDate snapshotRejectedOnDate;
@Column(name = "withdrawnon_date")
private LocalDate snapshotWithdrawnOnDate;
@Column(name = "closedon_date")
private LocalDate snapshotClosedOnDate;
@Column(name = "submittedon_userid")
private Long snapshotSubmittedOnUserId;
@Column(name = "approvedon_userid")
private Long snapshotApprovedOnUserId;
@Column(name = "activatedon_userid")
private Long snapshotActivatedOnUserId;
@Column(name = "rejectedon_userid")
private Long snapshotRejectedOnUserId;
@Column(name = "withdrawnon_userid")
private Long snapshotWithdrawnOnUserId;
@Column(name = "closedon_userid")
private Long snapshotClosedOnUserId;
@Column(name = "currency_code", length = 3)
private String snapshotCurrencyCode;
@Column(name = "currency_digits")
private Integer snapshotCurrencyDigits;
@Column(name = "currency_multiplesof")
private Integer snapshotCurrencyMultiplesOf;
@Column(name = "nominal_annual_interest_rate", precision = 19, scale = 6)
private BigDecimal snapshotNominalAnnualInterestRate;
@Column(name = "interest_compounding_period_enum")
private Integer snapshotInterestCompoundingPeriodEnum;
@Column(name = "interest_posting_period_enum")
private Integer snapshotInterestPostingPeriodEnum;
@Column(name = "interest_calculation_type_enum")
private Integer snapshotInterestCalculationTypeEnum;
@Column(name = "interest_calculation_days_in_year_type_enum")
private Integer snapshotInterestCalculationDaysInYearTypeEnum;
@Column(name = "min_balance_for_interest_calculation", precision = 19, scale = 6)
private BigDecimal snapshotMinBalanceForInterestCalculation;
@Column(name = "start_interest_calculation_date")
private LocalDate snapshotStartInterestCalculationDate;
@Column(name = "accrued_till_date")
private LocalDate snapshotAccruedTillDate;
@Column(name = "last_interest_calculation_date")
private LocalDate snapshotLastInterestCalculationDate;
@Column(name = "last_closed_business_date")
private LocalDate snapshotLastClosedBusinessDate;
@Column(name = "withhold_tax")
private Boolean snapshotWithholdTax;
@Column(name = "min_required_opening_balance", precision = 19, scale = 6)
private BigDecimal snapshotMinRequiredOpeningBalance;
@Column(name = "lockin_period_frequency")
private Integer snapshotLockinPeriodFrequency;
@Column(name = "lockin_period_frequency_enum")
private Integer snapshotLockinPeriodFrequencyEnum;
@Column(name = "lockedin_until_date_derived")
private LocalDate snapshotLockedInUntilDate;
@Column(name = "enforce_min_required_balance")
private Boolean snapshotEnforceMinRequiredBalance;
@Column(name = "min_required_balance", precision = 19, scale = 6)
private BigDecimal snapshotMinRequiredBalance;
@Column(name = "allow_overdraft")
private Boolean snapshotAllowOverdraft;
@Column(name = "overdraft_limit", precision = 19, scale = 6)
private BigDecimal snapshotOverdraftLimit;
@Column(name = "nominal_annual_interest_rate_overdraft", precision = 19, scale = 6)
private BigDecimal snapshotNominalAnnualInterestRateOverdraft;
@Column(name = "min_overdraft_for_interest_calculation", precision = 19, scale = 6)
private BigDecimal snapshotMinOverdraftForInterestCalculation;
@Column(name = "is_lien_allowed")
private Boolean snapshotLienAllowed;
@Column(name = "max_allowed_lien_limit", precision = 19, scale = 6)
private BigDecimal snapshotMaxAllowedLienLimit;
@Column(name = "on_hold_funds_derived", precision = 19, scale = 6)
private BigDecimal snapshotOnHoldFundsDerived;
@Column(name = "account_balance_derived", precision = 19, scale = 6)
private BigDecimal snapshotAccountBalanceDerived;
@Column(name = "total_deposits_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalDepositsDerived;
@Column(name = "total_withdrawals_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalWithdrawalsDerived;
@Column(name = "total_interest_earned_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalInterestEarnedDerived;
@Column(name = "total_interest_posted_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalInterestPostedDerived;
@Column(name = "total_overdraft_interest_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalOverdraftInterestDerived;
@Column(name = "total_withhold_tax_derived", precision = 19, scale = 6)
private BigDecimal snapshotTotalWithholdTaxDerived;
@Column(name = "total_savings_amount_on_hold", precision = 19, scale = 6)
private BigDecimal snapshotTotalSavingsAmountOnHold;
@Column(name = "withdrawal_fee_for_transfer")
private Boolean snapshotWithdrawalFeeForTransfer;
@Column(name = "reason_for_block", length = 500)
private String snapshotReasonForBlock;
@Column(name = "msme")
private Boolean snapshotMsme;
@Column(name = "auto_debit_facility")
private Boolean snapshotAutoDebitFacility;
@Column(name = "balance_swap_option")
private Boolean snapshotBalanceSwapOption;
@Column(name = "establishment_date")
private LocalDate snapshotEstablishmentDate;
@Column(name = "start_operation_date")
private LocalDate snapshotStartOperationDate;
@Column(name = "is_non_individual_client")
private Boolean snapshotNonIndividualClient;
@Column(name = "signatory_rights", length = 100)
private String snapshotSignatoryRights;
@Column(name = "cbs_account_type", length = 100)
private String snapshotCbsAccountType;
@Column(name = "customer_segment", length = 100)
private String snapshotCustomerSegment;
@Column(name = "account_status", length = 50)
private String snapshotAccountStatus;
@Column(name = "mode_of_operation", length = 50)
private String snapshotModeOfOperation;
@Column(name = "special_category", length = 100)
private String snapshotSpecialCategory;
@Column(name = "introducer_client_id")
private Long snapshotIntroducerClientId;
@Column(name = "introducer_name", length = 200)
private String snapshotIntroducerName;
@Column(name = "introducer_cif_number", length = 50)
private String snapshotIntroducerCifNumber;
@Column(name = "nominee_data", columnDefinition = "TEXT")
private String snapshotNomineeData;
@Column(name = "interest_calculate_selector")
private Integer snapshotInterestCalculateSelector;
@Column(name = "average_period", length = 20)
private String snapshotAveragePeriod;
@Column(name = "auto_post")
private Boolean snapshotAutoPost;
@Column(name = "frequency_fix_day")
private Integer snapshotFrequencyFixDay;
@Column(name = "enable_lockin_period")
private Boolean snapshotEnableLockinPeriod;
@Column(name = "balance_frequency", length = 50)
private String snapshotBalanceFrequency;
@Column(name = "avg_balance_calc_period", length = 10)
private String snapshotAvgBalanceCalcPeriod;
@Column(name = "account_closed_zero_balance_in_day_closing")
private Boolean snapshotAccountClosedZeroBalanceInDayClosing;
@Column(name = "valid_for_period", length = 10)
private String snapshotValidForPeriod;
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private Boolean snapshotOdAccountClosedZeroBalanceInDayClosing;
@Column(name = "is_linking_enable")
private Boolean snapshotShareLinkingEnabled;
@Column(name = "terminate_true")
private Boolean snapshotTerminateTrue;
protected CurrentAccountPendingEdit() {}
public CurrentAccountPendingEdit(final CurrentAccount currentAccount, final AppUser maker) {
this.currentAccount = currentAccount;
this.maker = maker;
this.createdOn = LocalDateTime.now();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(currentAccount.getId());
}
public static String lockKeyFor(final Long currentAccountId) {
return "CURRENT_ACCOUNT_EDIT:" + currentAccountId;
}
/**
* Builds a new pending-edit row and copies every business column off the live {@code account} onto this
* request's wide snapshot fields, for audit/UI display purposes only.
*/
public static CurrentAccountPendingEdit snapshotOf(final CurrentAccount account, final AppUser maker) {
final CurrentAccountPendingEdit p = new CurrentAccountPendingEdit(account, maker);
p.snapshotAccountNo = account.getAccountNo();
p.snapshotExternalId = account.getExternalId();
p.snapshotClientId = account.getClient() != null ? account.getClient().getId() : null;
p.snapshotProductId = account.getProductId();
p.snapshotOfficeId = account.getOffice() != null ? account.getOffice().getId() : null;
p.snapshotStaffId = account.getStaff() != null ? account.getStaff().getId() : null;
p.snapshotStatusEnum = account.getStatusEnum();
p.snapshotSubStatusEnum = account.getSubStatusEnum();
p.snapshotAccountTypeEnum = account.getAccountTypeEnum();
p.snapshotSubmittedOnDate = account.getSubmittedOnDate();
p.snapshotApprovedOnDate = account.getApprovedOnDate();
p.snapshotActivatedOnDate = account.getActivatedOnDate();
p.snapshotRejectedOnDate = account.getRejectedOnDate();
p.snapshotWithdrawnOnDate = account.getWithdrawnOnDate();
p.snapshotClosedOnDate = account.getClosedOnDate();
p.snapshotSubmittedOnUserId = account.getSubmittedOnByUser() != null ? account.getSubmittedOnByUser().getId() : null;
p.snapshotApprovedOnUserId = account.getApprovedOnByUser() != null ? account.getApprovedOnByUser().getId() : null;
p.snapshotActivatedOnUserId = account.getActivatedOnByUser() != null ? account.getActivatedOnByUser().getId() : null;
p.snapshotRejectedOnUserId = account.getRejectedOnByUser() != null ? account.getRejectedOnByUser().getId() : null;
p.snapshotWithdrawnOnUserId = account.getWithdrawnOnByUser() != null ? account.getWithdrawnOnByUser().getId() : null;
p.snapshotClosedOnUserId = account.getClosedOnByUser() != null ? account.getClosedOnByUser().getId() : null;
p.snapshotCurrencyCode = account.getCurrencyCode();
p.snapshotCurrencyDigits = account.getCurrencyDigits();
p.snapshotCurrencyMultiplesOf = account.getCurrencyMultiplesOf();
p.snapshotNominalAnnualInterestRate = account.getNominalAnnualInterestRate();
p.snapshotInterestCompoundingPeriodEnum = account.getInterestCompoundingPeriodEnum();
p.snapshotInterestPostingPeriodEnum = account.getInterestPostingPeriodEnum();
p.snapshotInterestCalculationTypeEnum = account.getInterestCalculationTypeEnum();
p.snapshotInterestCalculationDaysInYearTypeEnum = account.getInterestCalculationDaysInYearTypeEnum();
p.snapshotMinBalanceForInterestCalculation = account.getMinBalanceForInterestCalculation();
p.snapshotStartInterestCalculationDate = account.getStartInterestCalculationDate();
p.snapshotAccruedTillDate = account.getAccruedTillDate();
p.snapshotLastInterestCalculationDate = account.getLastInterestCalculationDate();
p.snapshotLastClosedBusinessDate = account.getLastClosedBusinessDate();
p.snapshotWithholdTax = account.isWithholdTax();
p.snapshotMinRequiredOpeningBalance = account.getMinRequiredOpeningBalance();
p.snapshotLockinPeriodFrequency = account.getLockinPeriodFrequency();
p.snapshotLockinPeriodFrequencyEnum = account.getLockinPeriodFrequencyEnum();
p.snapshotLockedInUntilDate = account.getLockedInUntilDate();
p.snapshotEnforceMinRequiredBalance = account.isEnforceMinRequiredBalance();
p.snapshotMinRequiredBalance = account.getMinRequiredBalance();
p.snapshotAllowOverdraft = account.isAllowOverdraft();
p.snapshotOverdraftLimit = account.getOverdraftLimit();
p.snapshotNominalAnnualInterestRateOverdraft = account.getNominalAnnualInterestRateOverdraft();
p.snapshotMinOverdraftForInterestCalculation = account.getMinOverdraftForInterestCalculation();
p.snapshotLienAllowed = account.isLienAllowed();
p.snapshotMaxAllowedLienLimit = account.getMaxAllowedLienLimit();
p.snapshotOnHoldFundsDerived = account.getOnHoldFundsDerived();
p.snapshotAccountBalanceDerived = account.getAccountBalanceDerived();
p.snapshotTotalDepositsDerived = account.getTotalDepositsDerived();
p.snapshotTotalWithdrawalsDerived = account.getTotalWithdrawalsDerived();
p.snapshotTotalInterestEarnedDerived = account.getTotalInterestEarnedDerived();
p.snapshotTotalInterestPostedDerived = account.getTotalInterestPostedDerived();
p.snapshotTotalOverdraftInterestDerived = account.getTotalOverdraftInterestDerived();
p.snapshotTotalWithholdTaxDerived = account.getTotalWithholdTaxDerived();
p.snapshotTotalSavingsAmountOnHold = account.getTotalSavingsAmountOnHold();
p.snapshotWithdrawalFeeForTransfer = account.isWithdrawalFeeForTransfer();
p.snapshotReasonForBlock = account.getReasonForBlock();
p.snapshotMsme = account.isMsme();
p.snapshotAutoDebitFacility = account.isAutoDebitFacility();
p.snapshotBalanceSwapOption = account.isBalanceSwapOption();
p.snapshotEstablishmentDate = account.getEstablishmentDate();
p.snapshotStartOperationDate = account.getStartOperationDate();
p.snapshotNonIndividualClient = account.isNonIndividualClient();
p.snapshotSignatoryRights = account.getSignatoryRights();
p.snapshotCbsAccountType = account.getCbsAccountType();
p.snapshotCustomerSegment = account.getCustomerSegment();
p.snapshotAccountStatus = account.getAccountStatus();
p.snapshotModeOfOperation = account.getModeOfOperation();
p.snapshotSpecialCategory = account.getSpecialCategory();
p.snapshotIntroducerClientId = account.getIntroducerClientId();
p.snapshotIntroducerName = account.getIntroducerName();
p.snapshotIntroducerCifNumber = account.getIntroducerCifNumber();
p.snapshotNomineeData = account.getNomineeData();
p.snapshotInterestCalculateSelector = account.getInterestCalculateSelector();
p.snapshotAveragePeriod = account.getAveragePeriod();
p.snapshotAutoPost = account.isAutoPost();
p.snapshotFrequencyFixDay = account.getFrequencyFixDay();
p.snapshotEnableLockinPeriod = account.isEnableLockinPeriod();
p.snapshotBalanceFrequency = account.getBalanceFrequency();
p.snapshotAvgBalanceCalcPeriod = account.getAvgBalanceCalcPeriod();
p.snapshotAccountClosedZeroBalanceInDayClosing = account.getAccountClosedZeroBalanceInDayClosing();
p.snapshotValidForPeriod = account.getValidForPeriod();
p.snapshotOdAccountClosedZeroBalanceInDayClosing = account.isOdAccountClosedZeroBalanceInDayClosing();
p.snapshotShareLinkingEnabled = account.isShareLinkingEnabled();
p.snapshotTerminateTrue = account.isTerminateTrue();
return p;
}
public boolean isPending() {
return STATUS_PENDING.equals(this.status);
}
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CurrentAccountPendingEditRepository extends JpaRepository<CurrentAccountPendingEdit, Long> {
Optional<CurrentAccountPendingEdit> findByCurrentAccountIdAndStatus(Long currentAccountId, String status);
List<CurrentAccountPendingEdit> findByStatus(String status);
}
@@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.exception;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown at edit-submission time when the account's available balance cannot cover the charge for
* the digital/account feature(s) the maker is requesting to enable.
*/
public class CurrentAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditInsufficientBalanceException(final Long currentAccountId, final BigDecimal availableBalance,
final BigDecimal requiredAmount) {
super("error.msg.currentaccount.edit.insufficient.balance",
"Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance
+ ", required: " + requiredAmount,
currentAccountId, availableBalance, requiredAmount);
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditPendingApprovalException(final Long currentAccountId) {
super("error.msg.currentaccount.edit.pending.approval.exists",
"Current account with identifier " + currentAccountId + " already has a Nominee/Digital-Feature edit pending approval",
currentAccountId);
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public CurrentAccountEditSelfApprovalNotAllowedException(final Long currentAccountId) {
super("error.msg.currentaccount.edit.self.approval.not.allowed",
"The maker of the pending Nominee/Digital-Feature edit for current account " + currentAccountId
+ " cannot also approve or reject it",
currentAccountId);
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class CurrentAccountNoPendingEditException extends AbstractPlatformDomainRuleException {
public CurrentAccountNoPendingEditException(final Long currentAccountId) {
super("error.msg.currentaccount.no.pending.edit",
"Current account with identifier " + currentAccountId + " has no Nominee/Digital-Feature edit awaiting approval",
currentAccountId);
}
}
@@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentaccount.service;
import java.util.Collection;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
public interface CurrentAccountEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long currentAccountId, JsonCommand command);
CommandProcessingResult approveEdit(Long currentAccountId);
CommandProcessingResult rejectEdit(Long currentAccountId, String rejectReason);
CurrentAccountPendingEditData retrievePendingEdit(Long currentAccountId);
Collection<CurrentAccountPendingEditData> retrieveAllPendingEdits();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.currentaccount.service;
import java.util.Set;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -42,4 +43,13 @@ public interface CurrentAccountWritePlatformService {
CommandProcessingResult deleteApplication(Long accountId);
CommandProcessingResult terminateAccount(Long accountId, JsonCommand command);
/**
* Resolves each of the given (newly-enabled) facility types to its mapped charge via
* {@code m_facility_charge_mapping}, creates a {@code CurrentAccountCharge} for it (skipping any facility type
* for which the account already has a paid, non-waived charge for the same mapped {@code Charge}), collects it
* from the account's running balance as of today, and posts the corresponding GL journal entries. Reuses the
* same private charge-creation-and-collection helper that {@code activate()} uses for activation-time charges.
*/
void applyAndCollectFacilityCharges(Long currentAccountId, Set<String> newlyEnabledFacilityTypes);
}
-975
View File
@@ -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.<init>(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.<init>(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.<init>(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.<init>(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
-18
View File
@@ -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
Binary file not shown.
@@ -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<Map<String, Object>> 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
@@ -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",
@@ -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<CustomerSearchResultData> 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<String> 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> T safely(final Supplier<T> 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());
}
}
}
@@ -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;
}
@@ -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<CustomerSearchResultData> search(String name, String accountNumber, String aadhaarNumber, String panNumber,
String address, List<String> accountTypes);
}
@@ -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<String> 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<CustomerSearchResultData> search(final String name, final String accountNumber, final String aadhaarNumber,
final String panNumber, final String address, final List<String> 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<String> 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<Object> 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<CustomerSearchResultData> {
@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<String> 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<String> parts, final String value) {
if (value != null && !value.isBlank()) {
parts.add(value);
}
}
}
}
@@ -39,23 +39,29 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.data.SavingsAccountData;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
import org.springframework.stereotype.Component;
@@ -76,6 +82,9 @@ public class CurrentAccountsApiResource {
private final CurrentAccountWritePlatformService currentAccountWritePlatformService;
private final CurrentAccountReadPlatformService currentAccountReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
@GET
@Path("template")
@@ -229,4 +238,104 @@ public class CurrentAccountsApiResource {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
}
@POST
@Path("{accountId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a Nominee/Digital-Feature edit for a Current Account",
description = "Edits are held in m_current_account_pending_edit (a pending row there is itself the lock) and only "
+ "applied to the real account once a checker approves - see /currentaccounts/{accountId}/edit/approve and /reject below.")
@ApiResponse(responseCode = "200", description = "OK")
public String submitEdit(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CURRENTACCOUNT");
final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.submitEditForApproval(accountId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Current Account Nominee/Digital-Feature edits pending approval")
@ApiResponse(responseCode = "200", description = "OK")
public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
final Collection<CurrentAccountPendingEditData> pendingEdits = this.currentAccountEditWritePlatformService
.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{accountId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Current Account, if any")
@ApiResponse(responseCode = "200", description = "OK")
public String retrievePendingEdit(@PathParam("accountId") final Long accountId, @Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
final CurrentAccountPendingEditData pendingEdit = this.currentAccountEditWritePlatformService.retrievePendingEdit(accountId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{accountId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Current Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the "
+ "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.")
@ApiResponse(responseCode = "200", description = "OK")
public String approvePendingEdit(@PathParam("accountId") final Long accountId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CURRENTACCOUNT")) {
throw new NoAuthorizationException("User has no authority to approve current account edits");
}
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.approveEdit(accountId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{accountId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Current Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.")
@ApiResponse(responseCode = "200", description = "OK")
public String rejectPendingEdit(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CURRENTACCOUNT")) {
throw new NoAuthorizationException("User has no authority to reject current account edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.currentAccountEditWritePlatformService.rejectEdit(accountId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -58,8 +58,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.core.service.SearchParameters;
@@ -69,9 +72,11 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountTemplateReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
@@ -95,6 +100,9 @@ public class SavingsAccountsApiResource {
private final BulkImportWorkbookService bulkImportWorkbookService;
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final SqlValidator sqlValidator;
private final SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<SavingsAccountPendingEditData> pendingEditApiJsonSerializer;
@GET
@Path("template")
@@ -413,6 +421,102 @@ public class SavingsAccountsApiResource {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
}
@POST
@Path("{accountId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a Nominee/Digital-Feature edit for a Savings Account",
description = "Edits are held in m_savings_account_pending_edit (a pending row there is itself the lock) and only "
+ "applied to the real account once a checker approves - see /savingsaccounts/{accountId}/edit/approve and /reject below.")
public String submitEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_SAVINGSACCOUNT");
final JsonCommand command = JsonCommand.fromJsonElement(accountId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.submitEditForApproval(accountId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Savings Account Nominee/Digital-Feature edits pending approval")
public String retrieveAllPendingEdits(@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME);
final Collection<SavingsAccountPendingEditData> pendingEdits = this.savingsAccountEditWritePlatformService
.retrieveAllPendingEdits();
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdits);
}
@GET
@Path("{accountId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending Nominee/Digital-Feature edit for a Savings Account, if any")
public String retrievePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME);
final SavingsAccountPendingEditData pendingEdit = this.savingsAccountEditWritePlatformService.retrievePendingEdit(accountId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{accountId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Savings Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold, posts any newly-enabled feature charges, and applies the "
+ "Nominee/Digital-Feature changes to the account. The maker of the edit cannot also approve it.")
public String approvePendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("User has no authority to approve savings account edits");
}
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.approveEdit(accountId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{accountId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Savings Account Nominee/Digital-Feature edit",
description = "Releases the submission-time hold without applying any change. The maker of the edit cannot also reject it.")
public String rejectPendingEdit(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("User has no authority to reject savings account edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.savingsAccountEditWritePlatformService.rejectEdit(accountId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
@DELETE
@Path("{accountId}")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,569 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.data.CurrentAccountPendingEditData;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEdit;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditInsufficientBalanceException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditPendingApprovalException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountEditSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNoPendingEditException;
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker edit flow for an ACTIVE current account's Nominee + Digital & Account Features. Mirrors
* {@code SavingsAccountEditWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit
* pattern: a row in {@code m_current_account_pending_edit} is itself the lock, and nothing is applied to the real
* {@code m_current_account_facility_config} / {@code m_client_nominee} tables until a checker approves.
*
* <p>
* Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at
* submission a HOLD is placed for the total charge amount (blocking that much of the available balance without
* touching it); on approval the hold is released and the charge is posted for real (via
* {@link CurrentAccountWritePlatformService#applyAndCollectFacilityCharges(Long, Set)}); on rejection the hold is
* simply released.
*/
@RequiredArgsConstructor
public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements CurrentAccountEditWritePlatformService {
/** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */
private static final Map<String, String> FACILITY_TYPES = new LinkedHashMap<>();
static {
FACILITY_TYPES.put("atmFacility", "ATM_FACILITY");
FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL");
FACILITY_TYPES.put("upiFacility", "UPI_FACILITY");
FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS");
FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK");
FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY");
FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS");
FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY");
FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER");
FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY");
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING");
FACILITY_TYPES.put("mobileBanking", "MOBILE_BANKING");
}
private final PlatformSecurityContext context;
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountPendingEditRepository pendingEditRepository;
private final CurrentAccountFacilityConfigRepository facilityConfigRepository;
private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository;
private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService;
private final CurrentAccountWritePlatformService currentAccountWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final CommandSourceRepository commandSourceRepository;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long currentAccountId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId)
.orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId));
if (this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.isPresent()) {
throw new CurrentAccountEditPendingApprovalException(currentAccountId);
}
final JsonObject root = command.parsedJson().getAsJsonObject();
final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig();
final CurrentAccountPendingEdit pendingEdit = CurrentAccountPendingEdit.snapshotOf(account, maker);
// ── Digital & Account Features ──
final Set<String> newlyEnabledFacilityTypes = new HashSet<>();
if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) {
final JsonObject fc = root.getAsJsonObject("accountFacilityConfig");
pendingEdit.setFacilityConfigSubmitted(true);
populateFacilityField(fc, "atmFacility", currentConfig != null && currentConfig.isAtmFacility(), pendingEdit::setAtmFacility,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "atmWithdrawalFacility", currentConfig != null && currentConfig.isAtmWithdrawalFacility(),
pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "upiFacility", currentConfig != null && currentConfig.isUpiFacility(), pendingEdit::setUpiFacility,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "posTransactionsFacility", currentConfig != null && currentConfig.isPosTransactionsFacility(),
pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "chequeBook", currentConfig != null && currentConfig.isChequeBook(), pendingEdit::setChequeBook,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "impsFacility", currentConfig != null && currentConfig.isImpsFacility(),
pendingEdit::setImpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "smsAlerts", currentConfig != null && currentConfig.isSmsAlerts(), pendingEdit::setSmsAlerts,
newlyEnabledFacilityTypes);
populateFacilityField(fc, "bbpsFacility", currentConfig != null && currentConfig.isBbpsFacility(),
pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "insuranceCover", currentConfig != null && currentConfig.isInsuranceCover(),
pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bepgFacility", currentConfig != null && currentConfig.isBepgFacility(),
pendingEdit::setBepgFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "neftFacility", currentConfig != null && currentConfig.isNeftFacility(),
pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "rtgsFacility", currentConfig != null && currentConfig.isRtgsFacility(),
pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(),
pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(),
pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(),
pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(),
pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
populateFacilityField(fc, "internetBanking", currentConfig != null && currentConfig.isInternetBanking(),
pendingEdit::setInternetBanking, newlyEnabledFacilityTypes);
populateFacilityField(fc, "mobileBanking", currentConfig != null && currentConfig.isMobileBanking(),
pendingEdit::setMobileBanking, newlyEnabledFacilityTypes);
}
// ── Nominee (single add / update / delete per request) ──
if (root.has("nominee") && root.get("nominee").isJsonObject()) {
final JsonObject nom = root.getAsJsonObject("nominee");
pendingEdit.setNomineeSubmitted(true);
pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null);
pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null);
pendingEdit.setNomineeClientId(
nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong()
: null);
pendingEdit.setRelationshipCvId(
nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null);
pendingEdit.setPercentageShare(
nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal()
: null);
pendingEdit.setNomineeIsActive(
nom.has("isActive") && !nom.get("isActive").isJsonNull() ? nom.get("isActive").getAsBoolean() : null);
pendingEdit.setNomineeIsMinor(
nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull() ? nom.get("nomineeIsMinor").getAsBoolean()
: null);
pendingEdit.setGuardianClientId(
nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong()
: null);
pendingEdit.setGuardianRelationshipCvId(
nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull()
? nom.get("guardianRelationshipId").getAsLong()
: null);
pendingEdit.setEffectiveDate(
nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString())
: null);
pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null);
}
// ── Hold the charge amount for newly-enabled facilities before anything is persisted ──
final BigDecimal holdNeeded = totalChargeAmountFor(newlyEnabledFacilityTypes);
if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived()
: BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal availableBalance = currentBalance.subtract(onHold);
if (availableBalance.compareTo(holdNeeded) < 0) {
throw new CurrentAccountEditInsufficientBalanceException(currentAccountId, availableBalance, holdNeeded);
}
final JsonObject holdJson = new JsonObject();
holdJson.addProperty("locale", "en");
holdJson.addProperty("dateFormat", "yyyy-MM-dd");
holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString());
holdJson.addProperty("transactionAmount", holdNeeded);
holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit");
final JsonCommand holdCommand = JsonCommand.fromJsonElement(currentAccountId, holdJson, this.fromApiJsonHelper);
final CommandProcessingResult holdResult = this.currentAccountTransactionWritePlatformService.holdAmount(currentAccountId,
holdCommand);
pendingEdit.setHoldTransactionId(holdResult.getResourceId());
pendingEdit.setHoldAmount(holdNeeded);
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("SUBMITEDIT") //
.entityName("CURRENTACCOUNT") //
.resourceGetUrl("/currentaccounts/" + currentAccountId) //
.resourceId(currentAccountId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long currentAccountId) {
final AppUser checker = this.context.authenticatedUser();
final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
final CurrentAccount account = this.currentAccountRepository.findById(currentAccountId)
.orElseThrow(() -> new CurrentAccountNotFoundException(currentAccountId));
final CurrentAccountFacilityConfig currentConfig = account.getFacilityConfig();
final Set<String> newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted()
? computeNewlyEnabled(pendingEdit, currentConfig)
: Set.of();
// 1) Release the hold placed at submission time.
if (pendingEdit.getHoldTransactionId() != null) {
this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
if (!newlyEnabledFacilityTypes.isEmpty()) {
this.currentAccountWritePlatformService.applyAndCollectFacilityCharges(currentAccountId, newlyEnabledFacilityTypes);
}
// 3) Apply the facility-config toggles to the real row.
if (pendingEdit.isFacilityConfigSubmitted()) {
CurrentAccountFacilityConfig config = account.getFacilityConfig();
if (config == null) {
config = CurrentAccountFacilityConfig.createFor(account);
account.setFacilityConfig(config);
}
config.updateFrom(buildFacilityConfigJson(pendingEdit));
this.facilityConfigRepository.saveAndFlush(config);
}
// 4) Apply the nominee change to the real table.
if (pendingEdit.isNomineeSubmitted()) {
applyNomineeChange(account, pendingEdit);
}
this.currentAccountRepository.saveAndFlush(account);
pendingEdit.markApproved(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, false);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long currentAccountId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final CurrentAccountPendingEdit pendingEdit = this.pendingEditRepository
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
if (pendingEdit.getHoldTransactionId() != null) {
this.currentAccountTransactionWritePlatformService.releaseAmount(currentAccountId, pendingEdit.getHoldTransactionId());
}
pendingEdit.markRejected(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, true);
return new CommandProcessingResultBuilder().withEntityId(currentAccountId).build();
}
@Override
public CurrentAccountPendingEditData retrievePendingEdit(final Long currentAccountId) {
return this.pendingEditRepository.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.map(this::toData).orElse(null);
}
@Override
public java.util.Collection<CurrentAccountPendingEditData> retrieveAllPendingEdits() {
return this.pendingEditRepository.findByStatus(CurrentAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList();
}
// ── helpers ──────────────────────────────────────────────────────────────
/**
* Sum of the mapped charge amounts for the given facility types - used to size the hold placed at
* edit-submission time, before any charge is actually applied.
*/
private BigDecimal totalChargeAmountFor(final Set<String> facilityTypes) {
BigDecimal total = BigDecimal.ZERO;
for (final String facilityType : facilityTypes) {
final var mapping = this.facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mapping.isPresent() && mapping.get().getCharge() != null) {
total = total.add(mapping.get().getCharge().getAmount());
}
}
return total;
}
private void applyNomineeChange(final CurrentAccount account, final CurrentAccountPendingEdit pendingEdit) {
final Long clientId = account.getClient() != null ? account.getClient().getId() : null;
final String operation = pendingEdit.getNomineeOperation();
if (CurrentAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) {
this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId());
return;
}
final JsonObject nomineeJson = new JsonObject();
nomineeJson.addProperty("locale", "en");
nomineeJson.addProperty("dateFormat", "yyyy-MM-dd");
nomineeJson.addProperty("accountNo", account.getAccountNo());
nomineeJson.addProperty("accountType", "CB");
if (pendingEdit.getNomineeClientId() != null) {
nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId());
}
if (pendingEdit.getRelationshipCvId() != null) {
nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId());
}
if (pendingEdit.getPercentageShare() != null) {
nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare());
}
if (pendingEdit.getNomineeIsActive() != null) {
nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive());
}
if (pendingEdit.getNomineeIsMinor() != null) {
nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor());
}
if (pendingEdit.getGuardianClientId() != null) {
nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId());
}
if (pendingEdit.getGuardianRelationshipCvId() != null) {
nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId());
}
if (pendingEdit.getEffectiveDate() != null) {
nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString());
}
if (pendingEdit.getNomineeRemarks() != null) {
nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks());
}
final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper);
if (CurrentAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) {
this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand);
} else if (CurrentAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) {
this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand);
}
}
private JsonObject buildFacilityConfigJson(final CurrentAccountPendingEdit p) {
final JsonObject fc = new JsonObject();
putIfPresent(fc, "atmFacility", p.getAtmFacility());
putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfPresent(fc, "upiFacility", p.getUpiFacility());
putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfPresent(fc, "chequeBook", p.getChequeBook());
putIfPresent(fc, "impsFacility", p.getImpsFacility());
putIfPresent(fc, "smsAlerts", p.getSmsAlerts());
putIfPresent(fc, "bbpsFacility", p.getBbpsFacility());
putIfPresent(fc, "insuranceCover", p.getInsuranceCover());
putIfPresent(fc, "bepgFacility", p.getBepgFacility());
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfPresent(fc, "internetBanking", p.getInternetBanking());
putIfPresent(fc, "mobileBanking", p.getMobileBanking());
return fc;
}
private void putIfPresent(final JsonObject json, final String field, final Boolean value) {
if (value != null) {
json.addProperty(field, value);
}
}
private Set<String> computeNewlyEnabled(final CurrentAccountPendingEdit p, final CurrentAccountFacilityConfig current) {
final Set<String> result = new HashSet<>();
addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current != null && current.isAtmFacility());
addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current != null && current.isAtmWithdrawalFacility());
addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current != null && current.isUpiFacility());
addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(),
current != null && current.isPosTransactionsFacility());
addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current != null && current.isChequeBook());
addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current != null && current.isImpsFacility());
addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current != null && current.isSmsAlerts());
addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current != null && current.isBbpsFacility());
addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current != null && current.isInsuranceCover());
addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current != null && current.isBepgFacility());
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current != null && current.isNeftFacility());
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(),
current != null && current.isInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard());
addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking());
addIfNewlyEnabled(result, "mobileBanking", p.getMobileBanking(), current != null && current.isMobileBanking());
return result;
}
private void addIfNewlyEnabled(final Set<String> result, final String field, final Boolean proposed, final boolean current) {
if (Boolean.TRUE.equals(proposed) && !current) {
result.add(FACILITY_TYPES.get(field));
}
}
/** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */
private void populateFacilityField(final JsonObject fc, final String field, final boolean currentValue,
final java.util.function.Consumer<Boolean> setter, final Set<String> newlyEnabledCollector) {
if (!fc.has(field) || fc.get(field).isJsonNull()) {
return;
}
final boolean proposed = fc.get(field).getAsBoolean();
setter.accept(proposed);
if (proposed && !currentValue) {
newlyEnabledCollector.add(FACILITY_TYPES.get(field));
}
}
private void updateAuditEntry(final CurrentAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) {
Optional<CommandSource> existing = Optional.empty();
if (pendingEdit.getCommandSourceId() != null) {
existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
}
final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() //
.actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") //
.entityName("CURRENTACCOUNT") //
.resourceGetUrl("/currentaccounts/" + pendingEdit.getCurrentAccount().getId()) //
.resourceId(pendingEdit.getCurrentAccount().getId()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build());
if (rejected) {
auditEntry.markAsRejected(checker);
} else {
auditEntry.markAsChecked(checker);
}
this.commandSourceRepository.saveAndFlush(auditEntry);
}
private CurrentAccountPendingEditData toData(final CurrentAccountPendingEdit p) {
final CurrentAccountPendingEditData data = new CurrentAccountPendingEditData();
data.setId(p.getId());
data.setCurrentAccountId(p.getCurrentAccount().getId());
data.setAccountNo(p.getCurrentAccount().getAccountNo());
data.setMakerId(p.getMaker().getId());
data.setMakerUsername(p.getMaker().getUsername());
if (p.getChecker() != null) {
data.setCheckerId(p.getChecker().getId());
data.setCheckerUsername(p.getChecker().getUsername());
}
data.setCreatedOn(p.getCreatedOn());
data.setCheckedOn(p.getCheckedOn());
data.setStatus(p.getStatus());
data.setRejectReason(p.getRejectReason());
data.setHoldAmount(p.getHoldAmount());
data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted());
data.setNomineeSubmitted(p.isNomineeSubmitted());
if (p.isFacilityConfigSubmitted()) {
final Map<String, Object> fc = new LinkedHashMap<>();
putIfNotNull(fc, "atmFacility", p.getAtmFacility());
putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfNotNull(fc, "upiFacility", p.getUpiFacility());
putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfNotNull(fc, "chequeBook", p.getChequeBook());
putIfNotNull(fc, "impsFacility", p.getImpsFacility());
putIfNotNull(fc, "smsAlerts", p.getSmsAlerts());
putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility());
putIfNotNull(fc, "insuranceCover", p.getInsuranceCover());
putIfNotNull(fc, "bepgFacility", p.getBepgFacility());
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfNotNull(fc, "internetBanking", p.getInternetBanking());
putIfNotNull(fc, "mobileBanking", p.getMobileBanking());
data.setProposedFacilityConfig(fc);
}
if (p.isNomineeSubmitted()) {
final Map<String, Object> nom = new LinkedHashMap<>();
putIfNotNull(nom, "operation", p.getNomineeOperation());
putIfNotNull(nom, "nomineeId", p.getNomineeId());
putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId());
putIfNotNull(nom, "relationshipId", p.getRelationshipCvId());
putIfNotNull(nom, "percentageShare", p.getPercentageShare());
putIfNotNull(nom, "isActive", p.getNomineeIsActive());
putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor());
putIfNotNull(nom, "guardianClientId", p.getGuardianClientId());
putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId());
putIfNotNull(nom, "effectiveDate", p.getEffectiveDate());
putIfNotNull(nom, "remarks", p.getNomineeRemarks());
data.setProposedNominee(nom);
}
return data;
}
private void putIfNotNull(final Map<String, Object> map, final String key, final Object value) {
if (value != null) {
map.put(key, value);
}
}
}
@@ -70,9 +70,37 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
response.put("partners", retrievePartners(accountId));
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
response.put("accountFacilityConfig", retrieveFacilityConfig(accountId));
return response;
}
private Map<String, Object> retrieveFacilityConfig(Long accountId) {
final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?";
List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> cfg = new LinkedHashMap<>();
cfg.put("atmFacility", rs.getBoolean("atm_facility"));
cfg.put("atmWithdrawalFacility", rs.getBoolean("atm_withdrawal_facility"));
cfg.put("upiFacility", rs.getBoolean("upi_facility"));
cfg.put("posTransactionsFacility", rs.getBoolean("pos_transactions_facility"));
cfg.put("chequeBook", rs.getBoolean("cheque_book"));
cfg.put("impsFacility", rs.getBoolean("imps_facility"));
cfg.put("smsAlerts", rs.getBoolean("sms_alerts"));
cfg.put("bbpsFacility", rs.getBoolean("bbps_facility"));
cfg.put("insuranceCover", rs.getBoolean("insurance_cover"));
cfg.put("bepgFacility", rs.getBoolean("bepg_facility"));
cfg.put("neftFacility", rs.getBoolean("neft_facility"));
cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility"));
cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility"));
cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility"));
cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc"));
cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card"));
cfg.put("internetBanking", rs.getBoolean("internet_banking"));
cfg.put("mobileBanking", rs.getBoolean("mobile_banking"));
return cfg;
}, accountId);
return rows.isEmpty() ? null : rows.get(0);
}
private List<Map<String, Object>> retrieveJointHolders(Long accountId) {
final String sql =
"SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
@@ -607,8 +635,18 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
private Map<String, Object> buildSummary(Map<String, Object> row) {
Map<String, Object> s = new LinkedHashMap<>();
s.put("accountBalance", row.get("account_balance_derived"));
s.put("availableBalance", row.get("account_balance_derived"));
BigDecimal balance = toBigDecimal(row.get("account_balance_derived"));
if (balance == null) {
balance = BigDecimal.ZERO;
}
BigDecimal onHold = toBigDecimal(row.get("on_hold_funds_derived"));
if (onHold == null) {
onHold = BigDecimal.ZERO;
}
BigDecimal available = balance.subtract(onHold);
s.put("accountBalance", balance);
s.put("availableBalance", available);
s.put("totalDeposits", row.get("total_deposits_derived"));
s.put("totalWithdrawals", row.get("total_withdrawals_derived"));
s.put("totalInterestEarned", row.get("total_interest_earned_derived"));
@@ -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;
}
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
@@ -335,25 +336,11 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : account.getCharges()) {
if (charge.getChargeTimeEnum() != null && charge.getChargeTimeEnum() == 3
&& charge.getAmount() != null
&& charge.getAmount().compareTo(java.math.BigDecimal.ZERO) > 0
&& charge.getCharge() != null) {
charge.setAmountPaidDerived(charge.getAmount());
charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO);
charge.setPaid(true);
currentBalance = currentBalance.subtract(charge.getAmount());
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.chargeDeduction(account, account.getOffice(), charge.getAmount(), activatedOnDate);
chargeTxn.setRunningBalanceDerived(currentBalance);
currentAccountTransactionRepository.saveAndFlush(chargeTxn);
chargeEntries.add(new ChargeGlEntry(chargeTxn, charge));
log.info("Auto-deducted activation charge '{}' {} from account id={}, running balance={}",
charge.getCharge().getName(), charge.getAmount(), accountId, currentBalance);
}
}
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
account.getCharges().stream()
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
.toList();
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account);
@@ -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<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
java.math.BigDecimal runningBalance, final LocalDate transactionDate,
final java.util.List<ChargeGlEntry> chargeEntriesOut) {
for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) {
if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) {
continue;
}
charge.setAmountPaidDerived(charge.getAmount());
charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO);
charge.setPaid(true);
runningBalance = runningBalance.subtract(charge.getAmount());
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate);
chargeTxn.setRunningBalanceDerived(runningBalance);
currentAccountTransactionRepository.saveAndFlush(chargeTxn);
chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge));
log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}",
charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance);
}
return runningBalance;
}
@Override
@Transactional
public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set<String> newlyEnabledFacilityTypes) {
if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) {
return;
}
final CurrentAccount account = findAccount(currentAccountId);
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> newCharges = new java.util.ArrayList<>();
for (final String facilityType : newlyEnabledFacilityTypes) {
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping",
facilityType);
continue;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
continue;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge);
account.getCharges().add(newCharge);
newCharges.add(newCharge);
}
if (newCharges.isEmpty()) {
return;
}
java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived()
: java.math.BigDecimal.ZERO;
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries);
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account);
postGlEntries(account, java.util.List.of(), chargeEntries);
}
// entity type for savings/current accounts in acc_gl_journal_entry
private static final int ENTITY_TYPE_SAVINGS = 2;
@@ -0,0 +1,510 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfig;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEdit;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditInsufficientBalanceException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditPendingApprovalException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountEditSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNoPendingEditException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker edit flow for an ACTIVE savings account's Nominee + Digital & Account Features. Mirrors
* {@code SavingsProductWritePlatformServiceJpaRepositoryImpl}'s submitEditForApproval/approveEdit/rejectEdit
* pattern: a row in {@code m_savings_account_pending_edit} is itself the lock, and nothing is applied to the real
* {@code m_savings_account_facility_config} / {@code m_client_nominee} tables until a checker approves.
*
* <p>
* Charge handling for a newly-enabled facility (one with a mapped charge in m_facility_charge_mapping): at
* submission a HOLD is placed for the total charge amount (blocking that much of the available balance without
* touching it); on approval the hold is released and the charge is posted for real; on rejection the hold is simply
* released.
*/
@RequiredArgsConstructor
public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements SavingsAccountEditWritePlatformService {
/** field name (as sent in accountFacilityConfig JSON / stored on the pending edit) -> facility_type code. */
private static final Map<String, String> FACILITY_TYPES = new LinkedHashMap<>();
static {
FACILITY_TYPES.put("atmFacility", "ATM_FACILITY");
FACILITY_TYPES.put("atmWithdrawalFacility", "ATM_WITHDRAWAL");
FACILITY_TYPES.put("upiFacility", "UPI_FACILITY");
FACILITY_TYPES.put("posTransactionsFacility", "POS_TRANSACTIONS");
FACILITY_TYPES.put("chequeBook", "CHEQUE_BOOK");
FACILITY_TYPES.put("impsFacility", "IMPS_FACILITY");
FACILITY_TYPES.put("smsAlerts", "SMS_ALERTS");
FACILITY_TYPES.put("bbpsFacility", "BBPS_FACILITY");
FACILITY_TYPES.put("insuranceCover", "INSURANCE_COVER");
FACILITY_TYPES.put("bepgFacility", "BEPG_FACILITY");
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
}
private final PlatformSecurityContext context;
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
private final SavingsAccountPendingEditRepository pendingEditRepository;
private final SavingsAccountFacilityConfigRepository facilityConfigRepository;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final CommandSourceRepository commandSourceRepository;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long savingsId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId);
if (this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING).isPresent()) {
throw new SavingsAccountEditPendingApprovalException(savingsId);
}
final JsonObject root = command.parsedJson().getAsJsonObject();
final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig();
final SavingsAccountPendingEdit pendingEdit = new SavingsAccountPendingEdit(account, maker);
// ── Digital & Account Features ──
final Set<String> newlyEnabledFacilityTypes = new HashSet<>();
if (root.has("accountFacilityConfig") && root.get("accountFacilityConfig").isJsonObject()) {
final JsonObject fc = root.getAsJsonObject("accountFacilityConfig");
pendingEdit.setFacilityConfigSubmitted(true);
populateFacilityField(fc, "atmFacility", currentConfig == null ? null : currentConfig.getAtmFacility(), pendingEdit::setAtmFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "atmWithdrawalFacility", currentConfig == null ? null : currentConfig.getAtmWithdrawalFacility(), pendingEdit::setAtmWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "upiFacility", currentConfig == null ? null : currentConfig.getUpiFacility(), pendingEdit::setUpiFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "posTransactionsFacility", currentConfig == null ? null : currentConfig.getPosTransactionsFacility(), pendingEdit::setPosTransactionsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "chequeBook", currentConfig == null ? null : currentConfig.getChequeBook(), pendingEdit::setChequeBook, newlyEnabledFacilityTypes);
populateFacilityField(fc, "impsFacility", currentConfig == null ? null : currentConfig.getImpsFacility(), pendingEdit::setImpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "smsAlerts", currentConfig == null ? null : currentConfig.getSmsAlerts(), pendingEdit::setSmsAlerts, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bbpsFacility", currentConfig == null ? null : currentConfig.getBbpsFacility(), pendingEdit::setBbpsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "insuranceCover", currentConfig == null ? null : currentConfig.getInsuranceCover(), pendingEdit::setInsuranceCover, newlyEnabledFacilityTypes);
populateFacilityField(fc, "bepgFacility", currentConfig == null ? null : currentConfig.getBepgFacility(), pendingEdit::setBepgFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
}
// ── Nominee (single add / update / delete per request) ──
if (root.has("nominee") && root.get("nominee").isJsonObject()) {
final JsonObject nom = root.getAsJsonObject("nominee");
pendingEdit.setNomineeSubmitted(true);
pendingEdit.setNomineeOperation(nom.has("operation") ? nom.get("operation").getAsString() : null);
pendingEdit.setNomineeId(nom.has("nomineeId") && !nom.get("nomineeId").isJsonNull() ? nom.get("nomineeId").getAsLong() : null);
pendingEdit.setNomineeClientId(nom.has("nomineeClientId") && !nom.get("nomineeClientId").isJsonNull() ? nom.get("nomineeClientId").getAsLong() : null);
pendingEdit.setRelationshipCvId(nom.has("relationshipId") && !nom.get("relationshipId").isJsonNull() ? nom.get("relationshipId").getAsLong() : null);
pendingEdit.setPercentageShare(nom.has("percentageShare") && !nom.get("percentageShare").isJsonNull() ? nom.get("percentageShare").getAsBigDecimal() : null);
pendingEdit.setNomineeIsActive(nom.has("isActive") && !nom.get("isActive").isJsonNull() ? nom.get("isActive").getAsBoolean() : null);
pendingEdit.setNomineeIsMinor(nom.has("nomineeIsMinor") && !nom.get("nomineeIsMinor").isJsonNull() ? nom.get("nomineeIsMinor").getAsBoolean() : null);
pendingEdit.setGuardianClientId(nom.has("guardianClientId") && !nom.get("guardianClientId").isJsonNull() ? nom.get("guardianClientId").getAsLong() : null);
pendingEdit.setGuardianRelationshipCvId(nom.has("guardianRelationshipId") && !nom.get("guardianRelationshipId").isJsonNull() ? nom.get("guardianRelationshipId").getAsLong() : null);
pendingEdit.setEffectiveDate(nom.has("effectiveDate") && !nom.get("effectiveDate").isJsonNull() ? LocalDate.parse(nom.get("effectiveDate").getAsString()) : null);
pendingEdit.setNomineeRemarks(nom.has("remarks") && !nom.get("remarks").isJsonNull() ? nom.get("remarks").getAsString() : null);
}
// ── Hold the charge amount for newly-enabled facilities before anything is persisted ──
final BigDecimal holdNeeded = this.facilityChargeAutoApplyService.totalChargeAmountFor(newlyEnabledFacilityTypes);
if (holdNeeded.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal availableBalance = account.getWithdrawableBalance();
if (availableBalance.compareTo(holdNeeded) < 0) {
throw new SavingsAccountEditInsufficientBalanceException(savingsId, availableBalance, holdNeeded);
}
final JsonObject holdJson = new JsonObject();
holdJson.addProperty("locale", "en");
holdJson.addProperty("dateFormat", "yyyy-MM-dd");
holdJson.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString());
holdJson.addProperty("transactionAmount", holdNeeded);
holdJson.addProperty("lienAllowed", false);
holdJson.addProperty("reasonForBlock", "Pending approval of Nominee/Digital-Feature edit");
final JsonCommand holdCommand = JsonCommand.fromJsonElement(savingsId, holdJson, this.fromApiJsonHelper);
final CommandProcessingResult holdResult = this.savingsAccountWritePlatformService.holdAmount(savingsId, holdCommand);
pendingEdit.setHoldTransactionId(holdResult.getResourceId());
pendingEdit.setHoldAmount(holdNeeded);
}
final CommandSource auditEntry = CommandSource.builder() //
.actionName("SUBMITEDIT") //
.entityName("SAVINGSACCOUNT") //
.resourceGetUrl("/savingsaccounts/" + savingsId) //
.resourceId(savingsId) //
.commandAsJson(command.json()) //
.maker(maker) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build();
this.commandSourceRepository.saveAndFlush(auditEntry);
pendingEdit.setCommandSourceId(auditEntry.getId());
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long savingsId) {
final AppUser checker = this.context.authenticatedUser();
final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
final SavingsAccount account = this.savingAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsId);
final SavingsAccountFacilityConfig currentConfig = account.getFacilityConfig();
final Set<String> newlyEnabledFacilityTypes = pendingEdit.isFacilityConfigSubmitted()
? computeNewlyEnabled(pendingEdit, currentConfig)
: Set.of();
// 1) Release the hold placed at submission time.
if (pendingEdit.getHoldTransactionId() != null) {
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
if (!newlyEnabledFacilityTypes.isEmpty()) {
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
}
// 3) Apply the facility-config toggles to the real row.
if (pendingEdit.isFacilityConfigSubmitted()) {
SavingsAccountFacilityConfig config = account.getFacilityConfig();
if (config == null) {
config = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(config);
}
config.updateFrom(buildFacilityConfigJson(pendingEdit));
this.facilityConfigRepository.saveAndFlush(config);
}
// 4) Apply the nominee change to the real table.
if (pendingEdit.isNomineeSubmitted()) {
applyNomineeChange(account, pendingEdit);
}
this.savingAccountRepositoryWrapper.saveAndFlush(account);
pendingEdit.markApproved(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, false);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long savingsId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final SavingsAccountPendingEdit pendingEdit = this.pendingEditRepository
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
if (pendingEdit.getHoldTransactionId() != null) {
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
pendingEdit.markRejected(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
updateAuditEntry(pendingEdit, checker, true);
return new CommandProcessingResultBuilder().withEntityId(savingsId).build();
}
@Override
public SavingsAccountPendingEditData retrievePendingEdit(final Long savingsId) {
return this.pendingEditRepository.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.map(this::toData).orElse(null);
}
@Override
public java.util.Collection<SavingsAccountPendingEditData> retrieveAllPendingEdits() {
return this.pendingEditRepository.findByStatus(SavingsAccountPendingEdit.STATUS_PENDING).stream().map(this::toData).toList();
}
// ── helpers ──────────────────────────────────────────────────────────────
private void applyNomineeChange(final SavingsAccount account, final SavingsAccountPendingEdit pendingEdit) {
final Long clientId = account.clientId();
final String operation = pendingEdit.getNomineeOperation();
if (SavingsAccountPendingEdit.NOMINEE_OP_DELETE.equals(operation)) {
this.clientNomineeWritePlatformService.deleteNominee(clientId, pendingEdit.getNomineeId());
return;
}
final String accountType;
if (account.depositAccountType().isFixedDeposit()) {
accountType = "FD";
} else if (account.depositAccountType().isRecurringDeposit()) {
accountType = "RD";
} else {
accountType = "SB";
}
final JsonObject nomineeJson = new JsonObject();
nomineeJson.addProperty("locale", "en");
nomineeJson.addProperty("dateFormat", "yyyy-MM-dd");
nomineeJson.addProperty("accountNo", account.getAccountNumber());
nomineeJson.addProperty("accountType", accountType);
if (pendingEdit.getNomineeClientId() != null) {
nomineeJson.addProperty("nomineeClientId", pendingEdit.getNomineeClientId());
}
if (pendingEdit.getRelationshipCvId() != null) {
nomineeJson.addProperty("relationshipId", pendingEdit.getRelationshipCvId());
}
if (pendingEdit.getPercentageShare() != null) {
nomineeJson.addProperty("percentageShare", pendingEdit.getPercentageShare());
}
if (pendingEdit.getNomineeIsActive() != null) {
nomineeJson.addProperty("isActive", pendingEdit.getNomineeIsActive());
}
if (pendingEdit.getNomineeIsMinor() != null) {
nomineeJson.addProperty("nomineeIsMinor", pendingEdit.getNomineeIsMinor());
}
if (pendingEdit.getGuardianClientId() != null) {
nomineeJson.addProperty("guardianClientId", pendingEdit.getGuardianClientId());
}
if (pendingEdit.getGuardianRelationshipCvId() != null) {
nomineeJson.addProperty("guardianRelationshipId", pendingEdit.getGuardianRelationshipCvId());
}
if (pendingEdit.getEffectiveDate() != null) {
nomineeJson.addProperty("effectiveDate", pendingEdit.getEffectiveDate().toString());
}
if (pendingEdit.getNomineeRemarks() != null) {
nomineeJson.addProperty("remarks", pendingEdit.getNomineeRemarks());
}
final JsonCommand nomineeCommand = JsonCommand.fromJsonElement(clientId, nomineeJson, this.fromApiJsonHelper);
if (SavingsAccountPendingEdit.NOMINEE_OP_ADD.equals(operation)) {
this.clientNomineeWritePlatformService.addNominee(clientId, nomineeCommand);
} else if (SavingsAccountPendingEdit.NOMINEE_OP_UPDATE.equals(operation)) {
this.clientNomineeWritePlatformService.updateNominee(clientId, pendingEdit.getNomineeId(), nomineeCommand);
}
}
private JsonObject buildFacilityConfigJson(final SavingsAccountPendingEdit p) {
final JsonObject fc = new JsonObject();
putIfPresent(fc, "atmFacility", p.getAtmFacility());
putIfPresent(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfPresent(fc, "upiFacility", p.getUpiFacility());
putIfPresent(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfPresent(fc, "chequeBook", p.getChequeBook());
putIfPresent(fc, "impsFacility", p.getImpsFacility());
putIfPresent(fc, "smsAlerts", p.getSmsAlerts());
putIfPresent(fc, "bbpsFacility", p.getBbpsFacility());
putIfPresent(fc, "insuranceCover", p.getInsuranceCover());
putIfPresent(fc, "bepgFacility", p.getBepgFacility());
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
return fc;
}
private void putIfPresent(final JsonObject json, final String field, final Boolean value) {
if (value != null) {
json.addProperty(field, value);
}
}
private Set<String> computeNewlyEnabled(final SavingsAccountPendingEdit p, final SavingsAccountFacilityConfig current) {
final Set<String> result = new HashSet<>();
addIfNewlyEnabled(result, "atmFacility", p.getAtmFacility(), current == null ? null : current.getAtmFacility());
addIfNewlyEnabled(result, "atmWithdrawalFacility", p.getAtmWithdrawalFacility(), current == null ? null : current.getAtmWithdrawalFacility());
addIfNewlyEnabled(result, "upiFacility", p.getUpiFacility(), current == null ? null : current.getUpiFacility());
addIfNewlyEnabled(result, "posTransactionsFacility", p.getPosTransactionsFacility(), current == null ? null : current.getPosTransactionsFacility());
addIfNewlyEnabled(result, "chequeBook", p.getChequeBook(), current == null ? null : current.getChequeBook());
addIfNewlyEnabled(result, "impsFacility", p.getImpsFacility(), current == null ? null : current.getImpsFacility());
addIfNewlyEnabled(result, "smsAlerts", p.getSmsAlerts(), current == null ? null : current.getSmsAlerts());
addIfNewlyEnabled(result, "bbpsFacility", p.getBbpsFacility(), current == null ? null : current.getBbpsFacility());
addIfNewlyEnabled(result, "insuranceCover", p.getInsuranceCover(), current == null ? null : current.getInsuranceCover());
addIfNewlyEnabled(result, "bepgFacility", p.getBepgFacility(), current == null ? null : current.getBepgFacility());
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility());
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard());
return result;
}
private void addIfNewlyEnabled(final Set<String> result, final String field, final Boolean proposed, final Boolean current) {
if (Boolean.TRUE.equals(proposed) && !Boolean.TRUE.equals(current)) {
result.add(FACILITY_TYPES.get(field));
}
}
/** Reads a single submitted field out of the incoming JSON, tracks whether it enables a previously-off facility. */
private void populateFacilityField(final JsonObject fc, final String field, final Boolean currentValue,
final java.util.function.Consumer<Boolean> setter, final Set<String> newlyEnabledCollector) {
if (!fc.has(field) || fc.get(field).isJsonNull()) {
return;
}
final boolean proposed = fc.get(field).getAsBoolean();
setter.accept(proposed);
if (proposed && !Boolean.TRUE.equals(currentValue)) {
newlyEnabledCollector.add(FACILITY_TYPES.get(field));
}
}
private void updateAuditEntry(final SavingsAccountPendingEdit pendingEdit, final AppUser checker, final boolean rejected) {
Optional<CommandSource> existing = Optional.empty();
if (pendingEdit.getCommandSourceId() != null) {
existing = this.commandSourceRepository.findById(pendingEdit.getCommandSourceId());
}
final CommandSource auditEntry = existing.orElseGet(() -> CommandSource.builder() //
.actionName(rejected ? "REJECTEDIT" : "APPROVEEDIT") //
.entityName("SAVINGSACCOUNT") //
.resourceGetUrl("/savingsaccounts/" + pendingEdit.getSavingsAccount().getId()) //
.resourceId(pendingEdit.getSavingsAccount().getId()) //
.maker(pendingEdit.getMaker()) //
.madeOnDate(DateUtils.getAuditOffsetDateTime()) //
.status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()) //
.idempotencyKey(UUID.randomUUID().toString()) //
.sanitized(false) //
.build());
if (rejected) {
auditEntry.markAsRejected(checker);
} else {
auditEntry.markAsChecked(checker);
}
this.commandSourceRepository.saveAndFlush(auditEntry);
}
private SavingsAccountPendingEditData toData(final SavingsAccountPendingEdit p) {
final SavingsAccountPendingEditData data = new SavingsAccountPendingEditData();
data.setId(p.getId());
data.setSavingsAccountId(p.getSavingsAccount().getId());
data.setAccountNo(p.getSavingsAccount().getAccountNumber());
data.setMakerId(p.getMaker().getId());
data.setMakerUsername(p.getMaker().getUsername());
if (p.getChecker() != null) {
data.setCheckerId(p.getChecker().getId());
data.setCheckerUsername(p.getChecker().getUsername());
}
data.setCreatedOn(p.getCreatedOn());
data.setCheckedOn(p.getCheckedOn());
data.setStatus(p.getStatus());
data.setRejectReason(p.getRejectReason());
data.setHoldAmount(p.getHoldAmount());
data.setFacilityConfigSubmitted(p.isFacilityConfigSubmitted());
data.setNomineeSubmitted(p.isNomineeSubmitted());
if (p.getSavingsAccount() != null) {
data.setDepositAccountType(p.getSavingsAccount().depositAccountType().name());
}
if (p.isFacilityConfigSubmitted()) {
final Map<String, Object> fc = new LinkedHashMap<>();
putIfNotNull(fc, "atmFacility", p.getAtmFacility());
putIfNotNull(fc, "atmWithdrawalFacility", p.getAtmWithdrawalFacility());
putIfNotNull(fc, "upiFacility", p.getUpiFacility());
putIfNotNull(fc, "posTransactionsFacility", p.getPosTransactionsFacility());
putIfNotNull(fc, "chequeBook", p.getChequeBook());
putIfNotNull(fc, "impsFacility", p.getImpsFacility());
putIfNotNull(fc, "smsAlerts", p.getSmsAlerts());
putIfNotNull(fc, "bbpsFacility", p.getBbpsFacility());
putIfNotNull(fc, "insuranceCover", p.getInsuranceCover());
putIfNotNull(fc, "bepgFacility", p.getBepgFacility());
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
data.setProposedFacilityConfig(fc);
}
if (p.isNomineeSubmitted()) {
final Map<String, Object> nom = new LinkedHashMap<>();
putIfNotNull(nom, "operation", p.getNomineeOperation());
putIfNotNull(nom, "nomineeId", p.getNomineeId());
putIfNotNull(nom, "nomineeClientId", p.getNomineeClientId());
putIfNotNull(nom, "relationshipId", p.getRelationshipCvId());
putIfNotNull(nom, "percentageShare", p.getPercentageShare());
putIfNotNull(nom, "isActive", p.getNomineeIsActive());
putIfNotNull(nom, "nomineeIsMinor", p.getNomineeIsMinor());
putIfNotNull(nom, "guardianClientId", p.getGuardianClientId());
putIfNotNull(nom, "guardianRelationshipId", p.getGuardianRelationshipCvId());
putIfNotNull(nom, "effectiveDate", p.getEffectiveDate());
putIfNotNull(nom, "remarks", p.getNomineeRemarks());
data.setProposedNominee(nom);
}
return data;
}
private void putIfNotNull(final Map<String, Object> map, final String key, final Object value) {
if (value != null) {
map.put(key, value);
}
}
}
@@ -152,6 +152,16 @@ import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatform
import org.apache.fineract.portfolio.savings.service.SavingsDropdownReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountEditWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfigRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPendingEditRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountEditWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.domain.RdBaseInterestRateEffectiveRepository;
@@ -468,6 +478,35 @@ public class SavingsConfiguration {
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
}
@Bean
@ConditionalOnMissingBean(SavingsAccountEditWritePlatformService.class)
public SavingsAccountEditWritePlatformService savingsAccountEditWritePlatformService(PlatformSecurityContext context,
SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper,
org.apache.fineract.portfolio.savings.domain.SavingsAccountPendingEditRepository pendingEditRepository,
SavingsAccountFacilityConfigRepository facilityConfigRepository, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
SavingsAccountWritePlatformService savingsAccountWritePlatformService,
org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService clientNomineeWritePlatformService,
org.apache.fineract.commands.domain.CommandSourceRepository commandSourceRepository, FromJsonHelper fromApiJsonHelper) {
return new SavingsAccountEditWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
pendingEditRepository, facilityConfigRepository, facilityChargeAutoApplyService, savingsAccountWritePlatformService,
clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper);
}
@Bean
@ConditionalOnMissingBean(CurrentAccountEditWritePlatformService.class)
public CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService(PlatformSecurityContext context,
CurrentAccountRepository currentAccountRepository, CurrentAccountPendingEditRepository pendingEditRepository,
CurrentAccountFacilityConfigRepository facilityConfigRepository,
org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository,
CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService,
CurrentAccountWritePlatformService currentAccountWritePlatformService,
ClientNomineeWritePlatformService clientNomineeWritePlatformService, CommandSourceRepository commandSourceRepository,
FromJsonHelper fromApiJsonHelper) {
return new CurrentAccountEditWritePlatformServiceJpaRepositoryImpl(context, currentAccountRepository, pendingEditRepository,
facilityConfigRepository, facilityChargeMappingRepository, currentAccountTransactionWritePlatformService,
currentAccountWritePlatformService, clientNomineeWritePlatformService, commandSourceRepository, fromApiJsonHelper);
}
@Bean
@ConditionalOnMissingBean(SavingsApplicationProcessWritePlatformService.class)
public SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService(PlatformSecurityContext context,
@@ -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> T safely(final Supplier<T> 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<SponsorBankData> 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;
}
}
@@ -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> T safely(final Supplier<T> 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<SponsorDDInventoryData> 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<SponsorDDLeafletData> 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<SponsorDDLeafletData> 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<SponsorDDSlipLine> 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<SponsorDDCashierDepositOptionData> 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<SponsorDDIssueRequestData> 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<SponsorDDIssueRequestData> 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<SponsorDDReprintRequestData> 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<SponsorDDGlMappingData> 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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData> 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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData> 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<SlipLineRequest> 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;
}
}
@@ -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<Line> lines) {
public record Line(Long issueRequestId, Long leafletId, String ddNumber, String beneficiaryName, BigDecimal faceValue) {}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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) {}
@@ -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<Long> {
@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;
}
@@ -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<SponsorBank, Long>, JpaSpecificationExecutor<SponsorBank> {
Optional<SponsorBank> findByName(String name);
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDAuditLog, Long>, JpaSpecificationExecutor<SponsorDDAuditLog> {
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDInventory, Long>, JpaSpecificationExecutor<SponsorDDInventory> {
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDLeaflet, Long>, JpaSpecificationExecutor<SponsorDDLeaflet> {
Optional<SponsorDDLeaflet> findByDdNumber(String ddNumber);
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDRecoBatch, Long>, JpaSpecificationExecutor<SponsorDDRecoBatch> {
Optional<SponsorDDRecoBatch> findByFileHash(String fileHash);
Optional<SponsorDDRecoBatch> findByBatchReference(String batchReference);
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDRecoRecord, Long>, JpaSpecificationExecutor<SponsorDDRecoRecord> {
List<SponsorDDRecoRecord> findByBatchId(Long batchId);
}
@@ -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<Long> {
@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;
}
@@ -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<SponsorDDTransaction, Long>, JpaSpecificationExecutor<SponsorDDTransaction> {
}
@@ -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<SponsorDDSlipLine> 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);
}
@@ -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<SponsorDDInventoryData> retrieveAllInventories();
SponsorDDInventoryData retrieveInventory(Long inventoryId);
Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(Long inventoryId);
SponsorDDLeafletData retrieveLeaflet(Long leafletId);
SponsorDDLeafletData retrieveLeafletByDdNumber(String ddNumber);
Collection<SponsorDDLeafletData> retrieveLeaflets(Long sponsorBankId, String status, String adviceStatus);
Collection<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData> retrieveAllReconciliationBatches();
org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData retrieveReconciliationBatch(Long batchId);
Collection<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData> retrieveReconciliationRecordsByBatch(Long batchId);
Collection<SponsorDDGlMappingData> retrieveAllGlMappings();
SponsorDDGlMappingData retrieveGlMappingBySponsorBank(Long sponsorBankId);
Collection<SponsorDDIssueRequestData> retrieveAllIssueRequests(String status);
Collection<SponsorDDIssueRequestData> retrieveMyIssueRequests(Long makerId);
SponsorDDIssueRequestData retrieveIssueRequest(Long issueRequestId);
Collection<SponsorDDReprintRequestData> 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<SponsorDDCashierDepositOptionData> retrieveAvailableCashierDepositRequests();
}
@@ -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<SponsorDDInventoryData> 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<SponsorDDLeafletData> 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<SponsorDDInventoryData> {
@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<SponsorDDLeafletData> {
@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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData> 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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData> 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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoBatchData> {
@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<org.apache.fineract.portfolio.sponsordd.data.SponsorDDRecoRecordData> {
@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<SponsorDDGlMappingData> 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<SponsorDDGlMappingData> {
@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<SponsorDDIssueRequestData> 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<SponsorDDIssueRequestData> 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<SponsorDDIssueRequestData> {
@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<SponsorDDReprintRequestData> 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<SponsorDDReprintRequestData> {
@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<SponsorDDLeafletData> retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) {
final List<Object> 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<SponsorDDCashierDepositOptionData> 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")));
}
}
@@ -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);
}
@@ -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<SponsorDDRecoBatch> 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<ParsedRecord> 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<SponsorDDRecoRecord> recoRecords = new ArrayList<>();
// 4. Run matching rules
for (final ParsedRecord pr : parsedRecords) {
final Optional<SponsorDDLeaflet> 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;
}
}
}
@@ -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);
}
@@ -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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> findLeafletOrThrow(final Long leafletId) {
final List<Map<String, Object>> 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<String, Object> findReprintRequestOrThrow(final Long reprintRequestId) {
final List<Map<String, Object>> 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.");
}
}
}
@@ -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);
}
@@ -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<SponsorDDLeaflet> 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<SponsorDDAuditLog> 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();
}
}
@@ -435,4 +435,29 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
prefix, so this causes no functional collision; the prefixes are just no longer strictly sequential
across the whole file. Left as-is rather than renumbering, since renumbering an already-created
migration risks Liquibase re-running it on any environment where it was already applied under its
original filename/id. -->
<include file="parts/0341_add_current_account_pending_edit_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0342_add_current_account_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0343_create_sponsor_bank_dd_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0344_create_sponsor_dd_issue_request_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0345_add_sponsor_dd_payable_gl_account_sbi.xml" relativeToChangelogFile="true"/>
<include file="parts/0347_add_sponsor_dd_walkin_cash_support.xml" relativeToChangelogFile="true"/>
<include file="parts/0348_add_sponsor_dd_cash_charges_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0349_replace_payment_type_with_sponsor_bank.xml" relativeToChangelogFile="true"/>
<include file="parts/0350_add_sponsor_dd_cash_receipt_suspense.xml" relativeToChangelogFile="true"/>
<include file="parts/0351_add_sponsor_dd_reprint_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0352_add_sponsor_dd_payable_at.xml" relativeToChangelogFile="true"/>
<include file="parts/0353_add_sponsor_dd_gst_on_charges.xml" relativeToChangelogFile="true"/>
<include file="parts/0354_add_sponsor_bank_identification_codes.xml" relativeToChangelogFile="true"/>
<include file="parts/0355_add_sponsor_dd_cashier_deposit_link.xml" relativeToChangelogFile="true"/>
<include file="parts/0356_add_dd_breakdown_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0357_add_dd_limit_to_sponsor_dd_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0358_add_batch_id_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0359_reclassify_dd_gst_payable_as_income.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Holds a pending edit request for an ACTIVE current account's Nominee + Digital & Account
Features, mirroring m_savings_account_pending_edit's maker-checker pattern: presence of a row
here with status=PENDING (enforced via the unique pending_lock_key) is itself the lock, and
nothing is applied to the real m_current_account_facility_config / m_client_nominee rows until
a checker approves (see CurrentAccountEditWritePlatformServiceJpaRepositoryImpl). One row per
request; only one nominee change (add/update/delete) can be proposed per request, so its fields
are kept as flat typed columns on this same table rather than a satellite table. Also carries a
full point-in-time "wide snapshot" of every business column on m_current_account at submission
time, for audit/UI display purposes only.
-->
<changeSet author="fineract" id="0341-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_current_account_pending_edit" />
</not>
</preConditions>
<createTable tableName="m_current_account_pending_edit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="current_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_caoe_current_account_id" references="m_current_account(id)" />
</column>
<column name="createdby_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_caoe_maker_id" references="m_appuser(id)" />
</column>
<column name="lastmodifiedby_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_checker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="lastmodified_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)" />
<column name="pending_lock_key" type="VARCHAR(40)">
<constraints nullable="true" unique="true" uniqueConstraintName="caoe_unq_pending_lock_key" />
</column>
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_command_source_id" references="m_portfolio_command_source(id)" />
</column>
<!-- Hold placed at submission time for the total charge of newly-enabled facilities; released
(no charge) on reject, released-then-converted-to-a-posted-charge on approve. -->
<column name="hold_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_hold_txn_id" references="m_current_account_transaction(id)" />
</column>
<column name="hold_amount" type="DECIMAL(19,6)" />
<!-- Digital & Account Features - same 18 columns/names as m_current_account_facility_config.
NULL = maker did not touch this toggle; facility_config_submitted tells approve()/UI
whether this block was submitted at all. -->
<column name="facility_config_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="atm_facility" type="BOOLEAN" />
<column name="atm_withdrawal_facility" type="BOOLEAN" />
<column name="upi_facility" type="BOOLEAN" />
<column name="pos_transactions_facility" type="BOOLEAN" />
<column name="cheque_book" type="BOOLEAN" />
<column name="imps_facility" type="BOOLEAN" />
<column name="sms_alerts" type="BOOLEAN" />
<column name="bbps_facility" type="BOOLEAN" />
<column name="insurance_cover" type="BOOLEAN" />
<column name="bepg_facility" type="BOOLEAN" />
<column name="neft_facility" type="BOOLEAN" />
<column name="rtgs_facility" type="BOOLEAN" />
<column name="inter_bank_total_cap_facility" type="BOOLEAN" />
<column name="withdrawal_facility" type="BOOLEAN" />
<column name="deposit_facility_kyc" type="BOOLEAN" />
<column name="virtual_debit_card" type="BOOLEAN" />
<column name="internet_banking" type="BOOLEAN" />
<column name="mobile_banking" type="BOOLEAN" />
<!-- Nominee change - one per request (add / update / delete), same fields as m_client_nominee. -->
<column name="nominee_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="nominee_operation" type="VARCHAR(10)" />
<column name="nominee_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_nominee_id" references="m_client_nominee(id)" />
</column>
<column name="nominee_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_nominee_client_id" references="m_client(id)" />
</column>
<column name="relationship_cv_id" type="BIGINT" />
<column name="percentage_share" type="DECIMAL(5,2)" />
<column name="nominee_is_active" type="BOOLEAN" />
<column name="nominee_is_minor" type="BOOLEAN" />
<column name="guardian_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_caoe_guardian_client_id" references="m_client(id)" />
</column>
<column name="guardian_relationship_cv_id" type="BIGINT" />
<column name="effective_date" type="DATE" />
<column name="nominee_remarks" type="VARCHAR(500)" />
<!-- Wide point-in-time snapshot of every business column on m_current_account at submission
time (audit/UI display only - approve()/reject() logic reads the live account, not this).
All nullable, no unique constraints, no defaults - this is a frozen copy. -->
<column name="account_no" type="VARCHAR(20)" />
<column name="external_id" type="VARCHAR(100)" />
<column name="client_id" type="BIGINT" />
<column name="product_id" type="BIGINT" />
<column name="office_id" type="BIGINT" />
<column name="staff_id" type="BIGINT" />
<column name="status_enum" type="INTEGER" />
<column name="sub_status_enum" type="INTEGER" />
<column name="account_type_enum" type="INTEGER" />
<column name="submittedon_date" type="DATE" />
<column name="approvedon_date" type="DATE" />
<column name="activatedon_date" type="DATE" />
<column name="rejectedon_date" type="DATE" />
<column name="withdrawnon_date" type="DATE" />
<column name="closedon_date" type="DATE" />
<column name="submittedon_userid" type="BIGINT" />
<column name="approvedon_userid" type="BIGINT" />
<column name="activatedon_userid" type="BIGINT" />
<column name="rejectedon_userid" type="BIGINT" />
<column name="withdrawnon_userid" type="BIGINT" />
<column name="closedon_userid" type="BIGINT" />
<column name="currency_code" type="VARCHAR(3)" />
<column name="currency_digits" type="INTEGER" />
<column name="currency_multiplesof" type="INTEGER" />
<column name="nominal_annual_interest_rate" type="DECIMAL(19,6)" />
<column name="interest_compounding_period_enum" type="INTEGER" />
<column name="interest_posting_period_enum" type="INTEGER" />
<column name="interest_calculation_type_enum" type="INTEGER" />
<column name="interest_calculation_days_in_year_type_enum" type="INTEGER" />
<column name="min_balance_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="start_interest_calculation_date" type="DATE" />
<column name="accrued_till_date" type="DATE" />
<column name="last_interest_calculation_date" type="DATE" />
<column name="last_closed_business_date" type="DATE" />
<column name="withhold_tax" type="BOOLEAN" />
<column name="min_required_opening_balance" type="DECIMAL(19,6)" />
<column name="lockin_period_frequency" type="INTEGER" />
<column name="lockin_period_frequency_enum" type="INTEGER" />
<column name="lockedin_until_date_derived" type="DATE" />
<column name="enforce_min_required_balance" type="BOOLEAN" />
<column name="min_required_balance" type="DECIMAL(19,6)" />
<column name="allow_overdraft" type="BOOLEAN" />
<column name="overdraft_limit" type="DECIMAL(19,6)" />
<column name="nominal_annual_interest_rate_overdraft" type="DECIMAL(19,6)" />
<column name="min_overdraft_for_interest_calculation" type="DECIMAL(19,6)" />
<column name="is_lien_allowed" type="BOOLEAN" />
<column name="max_allowed_lien_limit" type="DECIMAL(19,6)" />
<column name="on_hold_funds_derived" type="DECIMAL(19,6)" />
<column name="account_balance_derived" type="DECIMAL(19,6)" />
<column name="total_deposits_derived" type="DECIMAL(19,6)" />
<column name="total_withdrawals_derived" type="DECIMAL(19,6)" />
<column name="total_interest_earned_derived" type="DECIMAL(19,6)" />
<column name="total_interest_posted_derived" type="DECIMAL(19,6)" />
<column name="total_overdraft_interest_derived" type="DECIMAL(19,6)" />
<column name="total_withhold_tax_derived" type="DECIMAL(19,6)" />
<column name="total_savings_amount_on_hold" type="DECIMAL(19,6)" />
<column name="withdrawal_fee_for_transfer" type="BOOLEAN" />
<column name="reason_for_block" type="VARCHAR(500)" />
<column name="msme" type="BOOLEAN" />
<column name="auto_debit_facility" type="BOOLEAN" />
<column name="balance_swap_option" type="BOOLEAN" />
<column name="establishment_date" type="DATE" />
<column name="start_operation_date" type="DATE" />
<column name="is_non_individual_client" type="BOOLEAN" />
<column name="signatory_rights" type="VARCHAR(100)" />
<column name="cbs_account_type" type="VARCHAR(100)" />
<column name="customer_segment" type="VARCHAR(100)" />
<column name="account_status" type="VARCHAR(50)" />
<column name="mode_of_operation" type="VARCHAR(50)" />
<column name="special_category" type="VARCHAR(100)" />
<column name="introducer_client_id" type="BIGINT" />
<column name="introducer_name" type="VARCHAR(200)" />
<column name="introducer_cif_number" type="VARCHAR(50)" />
<column name="nominee_data" type="TEXT" />
<column name="interest_calculate_selector" type="INTEGER" />
<column name="average_period" type="VARCHAR(20)" />
<column name="auto_post" type="BOOLEAN" />
<column name="frequency_fix_day" type="INTEGER" />
<column name="enable_lockin_period" type="BOOLEAN" />
<column name="balance_frequency" type="VARCHAR(50)" />
<column name="avg_balance_calc_period" type="VARCHAR(10)" />
<column name="account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
<column name="valid_for_period" type="VARCHAR(10)" />
<column name="od_account_closed_zero_balance_in_day_closing" type="BOOLEAN" />
<column name="is_linking_enable" type="BOOLEAN" />
<column name="terminate_true" type="BOOLEAN" />
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Permissions gating the Nominee + Digital & Account Features maker-checker edit flow for
current accounts. SUBMITEDIT lets a maker propose the edit (this is the "Edit" action shown
on the account view); APPROVEEDIT/REJECTEDIT let a checker resolve it. The _CHECKER permission
follows the "<ACTION>_<ENTITY>_CHECKER" convention so the pending request also surfaces in the
generic Checker Inbox for limited checkers. Grouping matches the rest of CURRENTACCOUNT's
permissions in 0225_add_current_account_support.xml ("portfolio").
-->
<changeSet author="fineract" id="0342-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="SUBMITEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="SUBMITEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="APPROVEEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="APPROVEEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_CURRENTACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="REJECTEDIT_CURRENTACCOUNT" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="REJECTEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="0342-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_CURRENTACCOUNT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio" />
<column name="code" value="APPROVEEDIT_CURRENTACCOUNT_CHECKER" />
<column name="entity_name" value="CURRENTACCOUNT" />
<column name="action_name" value="APPROVEEDIT_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Per sponsor-bank GL account configuration: which GL account is credited when a DD is
issued (liability owed to the sponsor bank), and which suspense GL account is debited
when the customer pays by cheque instead of an account debit. -->
<changeSet author="fineract" id="0344-1-create-gl-mapping">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_sponsor_dd_gl_mapping" />
</not>
</preConditions>
<createTable tableName="m_sponsor_dd_gl_mapping">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="payment_type_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="sdd_glmap_unq_payment_type"
foreignKeyName="fk_sdd_glmap_payment_type_id" references="m_payment_type(id)" />
</column>
<column name="dd_payable_gl_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_glmap_payable_gl_id" references="acc_gl_account(id)" />
</column>
<column name="cheque_clearing_gl_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_glmap_clearing_gl_id" references="acc_gl_account(id)" />
</column>
<column name="created_date" type="DATETIME" defaultValueComputed="now()">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<!-- Maker-checker queue: clerk submits a PENDING request when a customer wants a DD issued
(either by debiting their own savings/current account, or by handing over a cheque);
a manager/head-cashier approval performs the actual GL posting and flips the leaflet
to ISSUED via the existing SponsorDDWritePlatformService.issueDemandDraft(). -->
<changeSet author="fineract" id="0344-2-create-issue-request">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_sponsor_dd_issue_request" />
</not>
</preConditions>
<createTable tableName="m_sponsor_dd_issue_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="leaflet_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_issuereq_leaflet_id" references="m_sponsor_dd_leaflet(id)" />
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_issuereq_client_id" references="m_client(id)" />
</column>
<column name="beneficiary_name" type="VARCHAR(150)">
<constraints nullable="false" />
</column>
<column name="face_value" type="DECIMAL(19,6)">
<constraints nullable="false" />
</column>
<column name="issuing_branch_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_issuereq_branch_id" references="m_office(id)" />
</column>
<column name="issued_date" type="DATE">
<constraints nullable="false" />
</column>
<!-- ACCOUNT_DEBIT or CHEQUE -->
<column name="payment_method" type="VARCHAR(20)">
<constraints nullable="false" />
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_savings_id" references="m_savings_account(id)" />
</column>
<column name="current_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_current_id" references="m_current_account(id)" />
</column>
<column name="cheque_number" type="VARCHAR(30)">
<constraints nullable="true" />
</column>
<column name="cheque_bank_name" type="VARCHAR(100)">
<constraints nullable="true" />
</column>
<!-- PENDING, APPROVED, REJECTED -->
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_issuereq_maker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME" defaultValueComputed="now()">
<constraints nullable="false" />
</column>
<column name="checker_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_checker_id" references="m_appuser(id)" />
</column>
<column name="checker_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="rejection_reason" type="VARCHAR(255)">
<constraints nullable="true" />
</column>
<column name="resulting_savings_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_result_savings_txn" references="m_savings_account_transaction(id)" />
</column>
<column name="resulting_current_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_result_current_txn" references="m_current_account_transaction(id)" />
</column>
<column name="resulting_journal_entry_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_result_journal" references="acc_gl_journal_entry(id)" />
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="0344-3-index-issue-request-status">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_sponsor_dd_issue_request" indexName="idx_sdd_issuereq_status" />
</not>
</preConditions>
<createIndex indexName="idx_sdd_issuereq_status" tableName="m_sponsor_dd_issue_request">
<column name="status" />
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
by "INTEREST PAYABLE" (gl_code 9.2). -->
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
</not>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Sponsor DD Payable - SBI"/>
<column name="gl_code" value="9.3"/>
<column name="parent_id" valueNumeric="22"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing."/>
</insert>
</changeSet>
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
set after the insert above. -->
<changeSet author="fineract" id="0345-2-fix-sponsor-dd-payable-sbi-hierarchy">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3' and hierarchy = '.'</sqlCheck>
</preConditions>
<sql>
UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.3' AND hierarchy = '.';
</sql>
</changeSet>
<!-- Wire the new GL account into the Sponsor DD GL mapping for the SBI payment type, so
DD issuance requests for SBI can be approved without manual configuration. -->
<changeSet author="fineract" id="0345-3-map-sponsor-dd-payable-sbi">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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'
</sqlCheck>
</preConditions>
<sql>
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';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Support DD issuance to a walk-in person paying cash who is not a bank client at all
(not just a different-branch customer). client_id becomes optional; when absent, the
walk-in purchaser's own KYC details are captured instead. Aadhaar is stored masked only
(last 4 digits) per UIDAI/RBI guidance — the full number is never persisted. -->
<changeSet author="fineract" id="0347-1-issue-request-client-id-nullable">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_sponsor_dd_issue_request" columnName="client_id" />
</preConditions>
<dropNotNullConstraint tableName="m_sponsor_dd_issue_request" columnName="client_id" columnDataType="BIGINT" />
</changeSet>
<changeSet author="fineract" id="0347-2-issue-request-add-walkin-fields">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="walkin_purchaser_name" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="walkin_purchaser_name" type="VARCHAR(150)">
<constraints nullable="true" />
</column>
<column name="walkin_purchaser_address" type="VARCHAR(255)">
<constraints nullable="true" />
</column>
<column name="walkin_purchaser_aadhaar_masked" type="VARCHAR(20)">
<constraints nullable="true" />
</column>
<column name="charges_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0347-3-leaflet-add-walkin-fields">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_leaflet" columnName="walkin_purchaser_name" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_leaflet">
<column name="walkin_purchaser_name" type="VARCHAR(150)">
<constraints nullable="true" />
</column>
<column name="walkin_purchaser_address" type="VARCHAR(255)">
<constraints nullable="true" />
</column>
<column name="walkin_purchaser_aadhaar_masked" type="VARCHAR(20)">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
<!-- GL accounts for a CASH payment: Dr Cash-in-Hand (face value + charges) / Cr Sponsor DD
Payable (face value) / Cr Charges Income (charges, if any). Both optional — CASH issuance
is blocked with a clear error until these are configured, matching the existing pattern
for cheque_clearing_gl_account_id. -->
<changeSet author="fineract" id="0347-4-gl-mapping-add-cash-and-charges-accounts">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="cash_gl_account_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_gl_mapping">
<column name="cash_gl_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_glmap_cash_gl_id" references="acc_gl_account(id)" />
</column>
<column name="charges_income_gl_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_glmap_charges_gl_id" references="acc_gl_account(id)" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Dedicated income GL account for charges/commission collected on sponsor bank DD issuance,
as a child of the existing "INCOME HEAD" account (gl_code 10), matching the pattern
already used by "INCOME FROM LOAN" (gl_code 10.1) / "INCOME FROM ALL" (gl_code 10.2). -->
<changeSet author="fineract" id="0348-1-insert-dd-charges-income-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '10.3'</sqlCheck>
</not>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="DD Issuance Charges Income"/>
<column name="gl_code" value="10.3"/>
<column name="parent_id" valueNumeric="25"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="4"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="Commission/charges income collected from customers and walk-in purchasers when issuing a sponsor bank demand draft."/>
</insert>
</changeSet>
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
set after the insert above. -->
<changeSet author="fineract" id="0348-2-fix-dd-charges-income-hierarchy">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '10.3' and hierarchy = '.'</sqlCheck>
</preConditions>
<sql>
UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '10.3' AND hierarchy = '.';
</sql>
</changeSet>
<!-- Wire "Cash at Tellers" (the standard Fineract GL account for cash physically received at
the teller counter, gl_code 2) and the new charges income account into the existing
Sponsor DD GL mapping row(s), so CASH-method walk-in DD issuance can be approved without
further manual configuration. -->
<changeSet author="fineract" id="0348-3-map-dd-cash-and-charges-accounts">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">
select count(*) from m_sponsor_dd_gl_mapping where cash_gl_account_id is null or charges_income_gl_account_id is null
</sqlCheck>
</not>
</preConditions>
<sql>
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;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0349-0-drop-old-sponsor-bank-table">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_bank" />
<not>
<columnExists tableName="m_sponsor_bank" columnName="createdby_id" />
</not>
</preConditions>
<sql>
DROP TABLE IF EXISTS m_sponsor_bank CASCADE;
</sql>
</changeSet>
<changeSet author="fineract" id="0349-1-create-sponsor-bank-table-v3">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_sponsor_bank" />
</not>
</preConditions>
<createTable tableName="m_sponsor_bank">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false" unique="true" uniqueConstraintName="sdd_bank_unq_name" />
</column>
<column name="description" type="VARCHAR(255)">
<constraints nullable="true" />
</column>
<column name="createdby_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_bank_maker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME" defaultValueComputed="now()">
<constraints nullable="false" />
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="0349-2-migrate-existing-payment-types">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_bank" />
<tableExists tableName="m_payment_type" />
</preConditions>
<sql>
-- 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);
</sql>
</changeSet>
<changeSet author="fineract" id="0349-3-add-sponsor-bank-id-to-inventory">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_inventory" />
<not>
<columnExists tableName="m_sponsor_dd_inventory" columnName="sponsor_bank_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_inventory">
<column name="sponsor_bank_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_inv_sponsor_bank_id" references="m_sponsor_bank(id)" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0349-4-update-inventory-sponsor-bank-ids">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_sponsor_dd_inventory" columnName="sponsor_bank_id" />
<columnExists tableName="m_sponsor_dd_inventory" columnName="payment_type_id" />
</preConditions>
<sql>
-- 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;
</sql>
</changeSet>
<changeSet author="fineract" id="0349-5-set-sponsor-bank-id-not-null">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_sponsor_dd_inventory" columnName="sponsor_bank_id" />
</preConditions>
<addNotNullConstraint tableName="m_sponsor_dd_inventory" columnName="sponsor_bank_id" columnDataType="BIGINT" />
</changeSet>
<changeSet author="fineract" id="0349-6-drop-payment-type-from-inventory">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_sponsor_dd_inventory" columnName="payment_type_id" />
</preConditions>
<dropForeignKeyConstraint baseTableName="m_sponsor_dd_inventory" constraintName="fk_sdd_inv_payment_type_id" />
<dropColumn tableName="m_sponsor_dd_inventory" columnName="payment_type_id" />
</changeSet>
<changeSet author="fineract" id="0349-7-decouple-gl-mapping-payment-type">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_gl_mapping" />
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="payment_type_id" />
</preConditions>
<dropForeignKeyConstraint baseTableName="m_sponsor_dd_gl_mapping" constraintName="fk_sdd_glmap_payment_type_id" />
<dropUniqueConstraint tableName="m_sponsor_dd_gl_mapping" constraintName="sdd_glmap_unq_payment_type" />
<dropColumn tableName="m_sponsor_dd_gl_mapping" columnName="payment_type_id" />
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Interim liability GL account for cash physically received from a walk-in DD purchaser at
submission time, before the manager has approved the request. Real-world equivalent of
"advance received against demand draft application" - keeps the cash movement auditable
immediately (the teller's cash-in-hand balance is correct as soon as cash is taken),
without prematurely crediting the Sponsor DD Payable liability, which is only actually
owed to the sponsor bank once the draft is approved/issued. -->
<changeSet author="fineract" id="0350-1-insert-dd-cash-suspense-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.4'</sqlCheck>
</not>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="DD Cash Received - Pending Approval"/>
<column name="gl_code" value="9.4"/>
<column name="parent_id" valueNumeric="22"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="Interim liability for cash physically received from a walk-in DD purchaser at submission time, pending manager approval of the issuance request. Cleared into Sponsor DD Payable + Charges Income on approval, or reversed back to Cash at Tellers on rejection."/>
</insert>
</changeSet>
<changeSet author="fineract" id="0350-2-fix-dd-cash-suspense-hierarchy">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.4' and hierarchy = '.'</sqlCheck>
</preConditions>
<sql>
UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.4' AND hierarchy = '.';
</sql>
</changeSet>
<changeSet author="fineract" id="0350-3-add-cash-suspense-to-gl-mapping">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_gl_mapping" />
<not>
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="cash_suspense_gl_account_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_gl_mapping">
<column name="cash_suspense_gl_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_glmap_cash_suspense_gl_id" references="acc_gl_account(id)" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0350-4-map-dd-cash-suspense-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">
select count(*) from m_sponsor_dd_gl_mapping where cash_suspense_gl_account_id is null
</sqlCheck>
</not>
</preConditions>
<sql>
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;
</sql>
</changeSet>
<!-- Audit trail: which journal entry recorded the initial cash receipt (posted at submission),
and which one reversed it (posted only if the request is later rejected). Both are nullable
since ACCOUNT_DEBIT/CHEQUE requests never populate them, and an approved CASH request only
ever gets a receipt entry (never a refund one). -->
<changeSet author="fineract" id="0350-5-add-cash-receipt-journal-entry-column">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="cash_receipt_journal_entry_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="cash_receipt_journal_entry_id" type="BIGINT">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0350-6-add-cash-refund-journal-entry-column">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="cash_refund_journal_entry_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="cash_refund_journal_entry_id" type="BIGINT">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A DD leaflet is a negotiable instrument - printing it a second time is a control risk
(duplicate physical drafts). The first print is free; any print after that requires a
reprint request approved by a higher authority (maker-checker), same pattern as issuance. -->
<changeSet author="fineract" id="0351-1-add-print-tracking-to-leaflet">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_leaflet" />
<not>
<columnExists tableName="m_sponsor_dd_leaflet" columnName="print_count" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_leaflet">
<column name="print_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false" />
</column>
<column name="last_printed_date" type="DATETIME">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0351-2-create-reprint-request-table">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_sponsor_dd_reprint_request" />
</not>
</preConditions>
<createTable tableName="m_sponsor_dd_reprint_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="leaflet_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_reprint_leaflet_id" references="m_sponsor_dd_leaflet(id)" />
</column>
<column name="reason" type="VARCHAR(500)">
<constraints nullable="false" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="consumed" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_sdd_reprint_maker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME" defaultValueComputed="now()">
<constraints nullable="false" />
</column>
<column name="checker_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_reprint_checker_id" references="m_appuser(id)" />
</column>
<column name="checker_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="rejection_reason" type="VARCHAR(1000)">
<constraints nullable="true" />
</column>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- "Payable at [Branch], [City]" - standard field on a demand draft telling the beneficiary
where it can be encashed. Nullable at the DB level (existing rows have neither); the
write service enforces it as required for new submissions. -->
<changeSet author="fineract" id="0352-1-add-payable-at-to-issue-request">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="payable_at_branch" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="payable_at_branch" type="VARCHAR(150)">
<constraints nullable="true" />
</column>
<column name="payable_at_city" type="VARCHAR(100)">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0352-2-add-payable-at-to-leaflet">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_leaflet" />
<not>
<columnExists tableName="m_sponsor_dd_leaflet" columnName="payable_at_branch" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_leaflet">
<column name="payable_at_branch" type="VARCHAR(150)">
<constraints nullable="true" />
</column>
<column name="payable_at_city" type="VARCHAR(100)">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- GST collected on DD issuance charges is money owed to the government, not bank income -
it gets its own liability GL account, separate from Charges Income. -->
<changeSet author="fineract" id="0353-1-insert-gst-payable-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.5'</sqlCheck>
</not>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="GST Payable on DD Charges"/>
<column name="gl_code" value="9.5"/>
<column name="parent_id" valueNumeric="22"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="GST collected from customers/purchasers on Sponsor DD issuance charges, held pending remittance to the government - not bank income."/>
</insert>
</changeSet>
<changeSet author="fineract" id="0353-2-fix-gst-payable-hierarchy">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.5' and hierarchy = '.'</sqlCheck>
</preConditions>
<sql>
UPDATE acc_gl_account SET hierarchy = CONCAT('.', id, '.') WHERE gl_code = '9.5' AND hierarchy = '.';
</sql>
</changeSet>
<!-- gst_percentage is deliberately a configurable column, not a hardcoded constant in Java -
it can be changed at any time (e.g. when the government revises the GST rate) via the
existing GL mapping admin endpoint, without a code deployment. Defaults to 18.00 (the
current GST rate on Indian banking service charges) for existing/new rows. -->
<changeSet author="fineract" id="0353-3-add-gst-columns-to-gl-mapping">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_gl_mapping" />
<not>
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="gst_percentage" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_gl_mapping">
<column name="gst_percentage" type="DECIMAL(5,2)" defaultValueNumeric="18.00">
<constraints nullable="false" />
</column>
<column name="gst_payable_gl_account_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_glmap_gst_gl_id" references="acc_gl_account(id)" />
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0353-4-map-gst-payable-account">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="0">
select count(*) from m_sponsor_dd_gl_mapping where gst_payable_gl_account_id is null
</sqlCheck>
</not>
</preConditions>
<sql>
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;
</sql>
</changeSet>
<!-- Locked in at submission time (not recomputed at approval), so a rate change between
submission and approval never silently alters a request already in flight. -->
<changeSet author="fineract" id="0353-5-add-gst-columns-to-issue-request">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="gst_amount" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="gst_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false" />
</column>
<column name="gst_percentage_applied" type="DECIMAL(5,2)" defaultValueNumeric="0">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Identification codes needed to route/reconcile settlement with the sponsor bank and
confirm its identity unambiguously - IFSC (settlement branch), RBI/NPCI bank code, and
SWIFT/BIC (only relevant if it's ever a foreign correspondent bank). All optional at the
DB level since existing rows have none, but the API validates IFSC format when supplied. -->
<changeSet author="fineract" id="0354-1-add-sponsor-bank-identification-codes">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_bank" />
<not>
<columnExists tableName="m_sponsor_bank" columnName="ifsc_code" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_bank">
<column name="ifsc_code" type="VARCHAR(11)">
<constraints nullable="true" />
</column>
<column name="bank_code" type="VARCHAR(20)">
<constraints nullable="true" />
</column>
<column name="swift_code" type="VARCHAR(11)">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- CASH DD requests no longer post their own "cash received" GL entry - the cashier records
the physical cash intake themselves via Transaction Entry (AC.TP=GL, target = the DD Cash
Suspense GL account), which is the existing, real cashier-position-tracked mechanism
(m_cashier_deposit_request + m_savings_txn_denomination). This column links a sponsor DD
issue request to the specific approved cashier deposit request that received its cash, so
we can validate the amount/account match and prevent the same cash being reused twice. -->
<changeSet author="fineract" id="0355-1-add-cashier-deposit-request-link">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<tableExists tableName="m_cashier_deposit_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="cashier_deposit_request_id" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="cashier_deposit_request_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_sdd_issuereq_cashier_deposit_id"
references="m_cashier_deposit_request(id)" />
</column>
</addColumn>
</changeSet>
<!-- The cashier's Transaction Entry GL-deposit flow always contras cash against "Cash at Vault"
(gl_code 1), not "Cash at Teller" (gl_code 2) - see CashierDepositRequestApiResource's
executeGlJournalEntry / the frontend's loadCashVaultGl(). Repoint our cash_gl_account_id to
match, since the reject/refund leg (Dr DD Cash Suspense / Cr this account) must reverse
against the same account the cashier's approved deposit actually touched. -->
<changeSet author="fineract" id="0355-2-repoint-cash-account-to-vault">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_gl_mapping" />
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '1'</sqlCheck>
</preConditions>
<sql>
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');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Transaction Entry's DD + Cash + GL row splits the single deposit amount into DD face value,
issuance charges and GST on those charges (Amount + Charges + GST, rounded up to the target
cash-collected total). Today m_cashier_deposit_request only stores that combined "amount" -
the breakdown is lost once the request is submitted, forcing whoever later issues the actual
DD leaflet in the Sponsor DD module to retype face value/charges from scratch. These columns
persist the breakdown at submit time (null for ordinary, non-DD deposit requests) so the DD
issuance screen can read it back via the linked cashier_deposit_request_id and pre-fill its
form instead of retyping. -->
<changeSet author="fineract" id="0356-1-add-dd-breakdown-to-cashier-deposit-request">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_cashier_deposit_request" />
<not>
<columnExists tableName="m_cashier_deposit_request" columnName="dd_face_value" />
</not>
</preConditions>
<addColumn tableName="m_cashier_deposit_request">
<column name="dd_face_value" type="DECIMAL(19,6)" />
<column name="dd_charges" type="DECIMAL(19,6)" />
<column name="dd_gst_percentage" type="DECIMAL(19,6)" />
<column name="dd_gst_amount" type="DECIMAL(19,6)" />
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Admin-configurable ceiling for a single DD's face value, also used as the ceiling for the
combined face value of all DDs issued to the same beneficiary on the same day (structuring
control for walk-in/large-cash DD issuance). Null/unset means no limit is enforced. -->
<changeSet author="fineract" id="0357-1-add-dd-limit-amount">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_sponsor_dd_gl_mapping"/>
<not>
<columnExists tableName="m_sponsor_dd_gl_mapping" columnName="dd_limit_amount"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_sponsor_dd_gl_mapping">
<column name="dd_limit_amount" type="DECIMAL(19,6)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Groups several DD issue requests submitted together as one "DD slip" (one customer account
debited/one cheque, several drafts for different beneficiaries/amounts). Set to the id of the
first row inserted for the slip - every row in the slip, including that first row, carries the
same batch_id, so the group can be queried/approved/rejected as one unit. Null for an ordinary
single-DD request (unchanged behaviour). No FK - it's a grouping tag, not a relationship to a
separate header row. -->
<changeSet author="fineract" id="0358-1-add-batch-id">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_sponsor_dd_issue_request"/>
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="batch_id"/>
</not>
</and>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="batch_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0358-2-index-batch-id">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_sponsor_dd_issue_request"/>
<not>
<indexExists tableName="m_sponsor_dd_issue_request" indexName="idx_sdd_issue_req_batch_id"/>
</not>
</and>
</preConditions>
<createIndex tableName="m_sponsor_dd_issue_request" indexName="idx_sdd_issue_req_batch_id">
<column name="batch_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Bank's own choice: book GST collected on DD charges as income rather than tracking it as a
payable owed to the government. Same GL account (gl_code 9.5), same id, so
m_sponsor_dd_gl_mapping.gst_payable_gl_account_id and the DD-issuance GL posting code need no
change at all - this only re-tags how the account is classified/grouped for reporting.
parent_id=25 matches the parent already used by "DD Issuance Charges Income" (gl_code 10.3,
see 0348), so both DD-related income accounts group together under the same income parent. -->
<changeSet author="fineract" id="0359-1-reclassify-gst-payable-as-income">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.5' and classification_enum = 2</sqlCheck>
</preConditions>
<sql>
UPDATE acc_gl_account
SET classification_enum = 4, parent_id = 25
WHERE gl_code = '9.5';
</sql>
</changeSet>
</databaseChangeLog>
@@ -932,6 +932,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) {
if (this.nominalAnnualInterestRateOverdraft == null) {
return BigDecimal.ZERO;
}
return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc);
}
@@ -0,0 +1,213 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Holds a pending edit request for an ACTIVE savings account's Nominee + Digital & Account Features.
* Presence of a row here with {@code status = PENDING} (enforced via the unique
* {@link #pendingLockKey}) is itself the lock - nothing is applied to the real
* {@code m_savings_account_facility_config} / {@code m_client_nominee} rows until a checker approves
* (see SavingsAccountEditWritePlatformServiceImpl). Only one nominee change (add/update/delete) can be
* proposed per request, so its fields are kept as flat typed columns on this same table.
*/
@Entity
@Table(name = "m_savings_account_pending_edit")
@Getter
@Setter
public class SavingsAccountPendingEdit extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String NOMINEE_OP_ADD = "ADD";
public static final String NOMINEE_OP_UPDATE = "UPDATE";
public static final String NOMINEE_OP_DELETE = "DELETE";
@ManyToOne
@JoinColumn(name = "savings_account_id", nullable = false)
private SavingsAccount savingsAccount;
@ManyToOne
@JoinColumn(name = "createdby_id", nullable = false)
private AppUser maker;
@ManyToOne
@JoinColumn(name = "lastmodifiedby_id")
private AppUser checker;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdOn;
@Column(name = "lastmodified_date")
private LocalDateTime checkedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
/** "SAVINGS_ACCOUNT_EDIT:<savingsAccountId>" while PENDING, NULL otherwise - the real DB-level lock. */
@Column(name = "pending_lock_key", length = 40)
private String pendingLockKey;
/** Links to the audit-only m_portfolio_command_source row created for this edit's submit/approve/reject. */
@Column(name = "command_source_id")
private Long commandSourceId;
@Column(name = "hold_transaction_id")
private Long holdTransactionId;
@Column(name = "hold_amount", precision = 19, scale = 6)
private BigDecimal holdAmount;
@Column(name = "facility_config_submitted", nullable = false)
private boolean facilityConfigSubmitted = false;
@Column(name = "atm_facility")
private Boolean atmFacility;
@Column(name = "atm_withdrawal_facility")
private Boolean atmWithdrawalFacility;
@Column(name = "upi_facility")
private Boolean upiFacility;
@Column(name = "pos_transactions_facility")
private Boolean posTransactionsFacility;
@Column(name = "cheque_book")
private Boolean chequeBook;
@Column(name = "imps_facility")
private Boolean impsFacility;
@Column(name = "sms_alerts")
private Boolean smsAlerts;
@Column(name = "bbps_facility")
private Boolean bbpsFacility;
@Column(name = "insurance_cover")
private Boolean insuranceCover;
@Column(name = "bepg_facility")
private Boolean bepgFacility;
@Column(name = "neft_facility")
private Boolean neftFacility;
@Column(name = "rtgs_facility")
private Boolean rtgsFacility;
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@Column(name = "virtual_debit_card")
private Boolean virtualDebitCard;
@Column(name = "nominee_submitted", nullable = false)
private boolean nomineeSubmitted = false;
@Column(name = "nominee_operation", length = 10)
private String nomineeOperation;
@Column(name = "nominee_id")
private Long nomineeId;
@Column(name = "nominee_client_id")
private Long nomineeClientId;
@Column(name = "relationship_cv_id")
private Long relationshipCvId;
@Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare;
@Column(name = "nominee_is_active")
private Boolean nomineeIsActive;
@Column(name = "nominee_is_minor")
private Boolean nomineeIsMinor;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "guardian_relationship_cv_id")
private Long guardianRelationshipCvId;
@Column(name = "effective_date")
private LocalDate effectiveDate;
@Column(name = "nominee_remarks", length = 500)
private String nomineeRemarks;
protected SavingsAccountPendingEdit() {}
public SavingsAccountPendingEdit(final SavingsAccount savingsAccount, final AppUser maker) {
this.savingsAccount = savingsAccount;
this.maker = maker;
this.createdOn = LocalDateTime.now();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(savingsAccount.getId());
}
public static String lockKeyFor(final Long savingsAccountId) {
return "SAVINGS_ACCOUNT_EDIT:" + savingsAccountId;
}
public boolean isPending() {
return STATUS_PENDING.equals(this.status);
}
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SavingsAccountPendingEditRepository extends JpaRepository<SavingsAccountPendingEdit, Long> {
Optional<SavingsAccountPendingEdit> findBySavingsAccountIdAndStatus(Long savingsAccountId, String status);
List<SavingsAccountPendingEdit> findByStatus(String status);
}
@@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.exception;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown at edit-submission time when the account's available balance cannot cover the charge for
* the digital/account feature(s) the maker is requesting to enable.
*/
public class SavingsAccountEditInsufficientBalanceException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditInsufficientBalanceException(final Long savingsId, final BigDecimal availableBalance,
final BigDecimal requiredAmount) {
super("error.msg.savingsaccount.edit.insufficient.balance",
"Account balance is insufficient for the requested feature(s). Available balance: " + availableBalance
+ ", required: " + requiredAmount,
savingsId, availableBalance, requiredAmount);
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditPendingApprovalException(final Long savingsId) {
super("error.msg.savingsaccount.edit.pending.approval",
"Savings account with identifier " + savingsId + " already has a Nominee/Digital-Feature edit pending approval",
savingsId);
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public SavingsAccountEditSelfApprovalNotAllowedException(final Long savingsId) {
super("error.msg.savingsaccount.edit.self.approval.not.allowed",
"The maker of the pending Nominee/Digital-Feature edit for savings account " + savingsId
+ " cannot also approve or reject it",
savingsId);
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class SavingsAccountNoPendingEditException extends AbstractPlatformDomainRuleException {
public SavingsAccountNoPendingEditException(final Long savingsId) {
super("error.msg.savingsaccount.no.pending.edit",
"Savings account with identifier " + savingsId + " has no Nominee/Digital-Feature edit awaiting approval", savingsId);
}
}
@@ -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<SavingsAccountCharge> 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<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) {
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> charges) {
final LocalDate activationDate = account.getActivationDate();
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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<String> facilityTypes) {
BigDecimal total = BigDecimal.ZERO;
for (final String facilityType : facilityTypes) {
final java.util.Optional<FacilityChargeMapping> mapping = mappingRepository.findByFacilityType(facilityType);
if (mapping.isPresent() && mapping.get().getCharge() != null) {
total = total.add(mapping.get().getCharge().getAmount());
}
}
return total;
}
/**
* Adds a fresh {@link SavingsAccountCharge} for each of the given (newly re-enabled) facility types, so approving
* a Nominee/Digital-Feature edit always charges again for the transition - unlike
* {@link #applyFacilityChargesOnActivation(SavingsAccount)}'s {@code applyIfEnabled}, this deliberately has NO
* "already applied" dedup-by-name guard: a facility that was enabled at activation, disabled, then re-enabled via
* an approved edit must be charged again, even though a (now-paid, now-irrelevant) charge object with the same
* name already sits in {@code account.charges()} from the earlier cycle.
*/
public List<SavingsAccountCharge> applyChargesForFacilities(final SavingsAccount account, final java.util.Set<String> facilityTypes) {
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
for (final String facilityType : facilityTypes) {
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) {
return;
}
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
});
}
if (!chargesToApply.isEmpty()) {
savingsAccountChargeRepository.saveAll(chargesToApply);
}
return chargesToApply;
}
/**
* Pays exactly the given charges (typically the just-added result of {@link #applyChargesForFacilities}) as of
* {@code transactionDate}. Unlike {@link #collectActivationCharges(SavingsAccount)} - which posts at the
* account's activation date and sweeps every unpaid activation-type charge - this is scoped to a specific set of
* charges and a caller-supplied date, for posting a Nominee/Digital-Feature edit's charges on the day it's
* approved rather than backdating them to activation.
*/
public List<SavingsAccountTransaction> collectCharges(final SavingsAccount account, final List<SavingsAccountCharge> charges,
final LocalDate transactionDate) {
final List<SavingsAccountTransaction> transactions = new ArrayList<>();
for (final SavingsAccountCharge charge : charges) {
if (charge.isPaid() || charge.isWaived()) continue;
final BigDecimal amount = charge.getAmountOutstanding(account.getCurrency()).getAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) continue;
final Money amountMoney = Money.of(account.getCurrency(), amount);
final SavingsAccountTransaction txn = account.payCharge(charge, amountMoney, transactionDate, false, null);
if (txn != null) {
transactions.add(txn);
log.info("Collected facility charge '{}' ₹{} from savings account id={}", charge.getCharge().getName(), amount,
account.getId());
}
}
if (!transactions.isEmpty()) {
savingsAccountTransactionRepository.saveAll(transactions);
account.recalculateBalancesAfterFacilityCharges(transactionDate);
}
return transactions;
}
/**
* Collects all unpaid SavingsActivation charges on the account.
* Called after facility charges are saved and flushed, so amounts are properly initialized.
*/
public List<SavingsAccountTransaction> collectActivationCharges(final SavingsAccount account) {
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> charges) {
final LocalDate activationDate = account.getActivationDate();
final List<SavingsAccountTransaction> 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<SavingsAccountCharge> 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<SavingsAccountCharge> 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);
}
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.data.SavingsAccountPendingEditData;
public interface SavingsAccountEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long savingsId, JsonCommand command);
CommandProcessingResult approveEdit(Long savingsId);
CommandProcessingResult rejectEdit(Long savingsId, String rejectReason);
SavingsAccountPendingEditData retrievePendingEdit(Long savingsId);
java.util.Collection<SavingsAccountPendingEditData> retrieveAllPendingEdits();
}
@@ -51,4 +51,6 @@
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Holds a pending edit request for an ACTIVE savings account's Nominee + Digital & Account
Features, mirroring the m_savings_product_temp maker-checker pattern: presence of a row here
with status=PENDING (enforced via the unique pending_lock_key) is itself the lock, and nothing
is applied to the real m_savings_account_facility_config / m_client_nominee rows until a
checker approves (see SavingsAccountEditWritePlatformServiceImpl). One row per request; only
one nominee change (add/update/delete) can be proposed per request, so its fields are kept as
flat typed columns on this same table rather than a satellite table.
-->
<changeSet author="fineract" id="2030-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_savings_account_pending_edit" />
</not>
</preConditions>
<createTable tableName="m_savings_account_pending_edit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_saoe_savings_account_id" references="m_savings_account(id)" />
</column>
<column name="createdby_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_saoe_maker_id" references="m_appuser(id)" />
</column>
<column name="lastmodifiedby_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_checker_id" references="m_appuser(id)" />
</column>
<column name="created_date" type="DATETIME">
<constraints nullable="false" />
</column>
<column name="lastmodified_date" type="DATETIME">
<constraints nullable="true" />
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="reject_reason" type="VARCHAR(500)" />
<column name="pending_lock_key" type="VARCHAR(40)">
<constraints nullable="true" unique="true" uniqueConstraintName="saoe_unq_pending_lock_key" />
</column>
<column name="command_source_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_command_source_id" references="m_portfolio_command_source(id)" />
</column>
<!-- Hold placed at submission time for the total charge of newly-enabled facilities; released
(no charge) on reject, released-then-converted-to-a-posted-charge on approve. -->
<column name="hold_transaction_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_hold_txn_id" references="m_savings_account_transaction(id)" />
</column>
<column name="hold_amount" type="DECIMAL(19,6)" />
<!-- Digital & Account Features - same 16 columns/names as m_savings_account_facility_config.
NULL = maker did not touch this toggle; facility_config_submitted tells approve()/UI
whether this block was submitted at all. -->
<column name="facility_config_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="atm_facility" type="BOOLEAN" />
<column name="atm_withdrawal_facility" type="BOOLEAN" />
<column name="upi_facility" type="BOOLEAN" />
<column name="pos_transactions_facility" type="BOOLEAN" />
<column name="cheque_book" type="BOOLEAN" />
<column name="imps_facility" type="BOOLEAN" />
<column name="sms_alerts" type="BOOLEAN" />
<column name="bbps_facility" type="BOOLEAN" />
<column name="insurance_cover" type="BOOLEAN" />
<column name="bepg_facility" type="BOOLEAN" />
<column name="neft_facility" type="BOOLEAN" />
<column name="rtgs_facility" type="BOOLEAN" />
<column name="inter_bank_total_cap_facility" type="BOOLEAN" />
<column name="withdrawal_facility" type="BOOLEAN" />
<column name="deposit_facility_kyc" type="BOOLEAN" />
<column name="virtual_debit_card" type="BOOLEAN" />
<!-- Nominee change - one per request (add / update / delete), same fields as m_client_nominee. -->
<column name="nominee_submitted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false" />
</column>
<column name="nominee_operation" type="VARCHAR(10)" />
<column name="nominee_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_nominee_id" references="m_client_nominee(id)" />
</column>
<column name="nominee_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_nominee_client_id" references="m_client(id)" />
</column>
<column name="relationship_cv_id" type="BIGINT" />
<column name="percentage_share" type="DECIMAL(5,2)" />
<column name="nominee_is_active" type="BOOLEAN" />
<column name="nominee_is_minor" type="BOOLEAN" />
<column name="guardian_client_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_saoe_guardian_client_id" references="m_client(id)" />
</column>
<column name="guardian_relationship_cv_id" type="BIGINT" />
<column name="effective_date" type="DATE" />
<column name="nominee_remarks" type="VARCHAR(500)" />
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Permissions gating the Nominee + Digital & Account Features maker-checker edit flow for
savings accounts. SUBMITEDIT lets a maker propose the edit (this is the "Edit" action shown
on the account view); APPROVEEDIT/REJECTEDIT let a checker resolve it. The _CHECKER permission
follows the "<ACTION>_<ENTITY>_CHECKER" convention (see 2022_add_product_checker_permissions.xml)
so the pending request also surfaces in the generic Checker Inbox for limited checkers.
-->
<changeSet author="fineract" id="2031-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="SUBMITEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="SUBMITEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="APPROVEEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="APPROVEEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-3">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'REJECTEDIT_SAVINGSACCOUNT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="REJECTEDIT_SAVINGSACCOUNT" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="REJECTEDIT" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
<changeSet author="fineract" id="2031-4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVEEDIT_SAVINGSACCOUNT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="transaction_savings" />
<column name="code" value="APPROVEEDIT_SAVINGSACCOUNT_CHECKER" />
<column name="entity_name" value="SAVINGSACCOUNT" />
<column name="action_name" value="APPROVEEDIT_CHECKER" />
<column name="can_maker_checker" valueBoolean="false" />
</insert>
</changeSet>
</databaseChangeLog>