Compare commits

...
Author SHA1 Message Date
Sonali Likhitkar a1de1aab93 implementation of loan product creation
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-01 11:13:33 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +05:30
nitesh 8724c54d9b Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
2026-08-19 10:50:25 +05:30
nitesh 5c0a482f49 feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable
Every mobile-number lookup was calling out to a local mock HTTP service
on port 8090 that had to be started separately, so the check failed
with a connection error whenever that process wasn't running.

Ports the mock's full verdict table (suspected, disconnected,
suspected+disconnected, clean) into MobileLookupServiceImpl itself, so
the demo/test numbers resolve in-process with no HTTP call. Adds
fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL
call entirely for local work; every number then resolves CLEAN and is
stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken
for a genuine pass. Must be turned back on before UAT/production.
2026-08-19 10:35:48 +05:30
nitesh 0e07e60328 feat(current-accounts): add document upload/list/download/delete endpoints
/v1/currentaccounts/{id}/documents had no handler at all -- the
account's own /v1/currentaccounts resource matches the request path
first and has no documents method, so it never fell through to the
generic /v1/{entityType}/{entityId}/documents resource that every
other entity type relies on. Current accounts got a 404 on every
Documents-tab request.

Delegates straight to the existing DocumentApiResource so current
accounts get identical behaviour to clients, loans, etc.
2026-08-19 10:35:35 +05:30
nitesh e227e8ae0f fix(savings): post GL entries for facility-charge collections at edit approval
Charges collected when a maker-checker Nominee/Digital-Feature edit is
approved deducted the account balance and appeared in the passbook,
but never posted a journal entry -- collectCharges() saved the charge
transaction directly and skipped the accounting bridge entirely, so
the GL never balanced against the subledger.

Also fixes an unrelated NPE this exposed: collectCharges() used
saveAll() instead of saveAllAndFlush(), so the new charge transaction
had no id yet when the journal-entry code tried to read it.
2026-08-19 10:35:18 +05:30
nitesh 231919b62b fix(savings): stop crediting accounts automatically on activation
Activation for Savings, RD, FD and Current accounts no longer books an
opening deposit or enforces a minimum opening balance -- accounts go
active at zero balance and a clerk deposits funds afterward as a
separate transaction, matching how these branches actually operate.

Fixed Deposit and Recurring Deposit have their own override of
activateWithBalance() that a prior fix to the shared SavingsAccount
base class missed, so they kept auto-depositing after Savings was
already fixed. Removing that auto-deposit exposed two follow-on bugs
in the FD/RD activation flow, now also fixed:
- the opening deposit's journal entries were posted twice (once by the
  deposit helper, once by the activation command's own posting call)
- FD/RD maturity amount was computed from the account's real (now
  empty) transaction history at activation and never recalculated once
  a deposit actually landed, so it showed no projected interest until
  the account was reloaded from a lucky code path
2026-08-19 10:34:54 +05:30
36 changed files with 3827 additions and 251 deletions
+10 -1
View File
@@ -37,4 +37,13 @@ gradleExp/
.windsurf/
# Application logs
fineract-provider/logs/
fineract-provider/logs/
logs/
# Output of a local `gradlew devRun`
devrun.log
# JVM crash dumps. The dev setup runs Fineract, the Angular dev server and Gradle on one
# machine, so an out-of-memory kill drops these in the repo root.
hs_err_pid*.log
replay_pid*.log
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -1466,6 +1466,26 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
/**
* The legacy "Account Type Creation" fields, flat and keyed by their form field name.
*
* Set after construction rather than through a constructor parameter: the main constructor already
* takes over a hundred arguments, and threading two more through it would touch every call site for
* no benefit. Follows the same pattern as setLoanProductConfigurableAttributes above.
*/
private Map<String, Object> cbsExtensions;
/** The legacy Interest Rate Detail grid: one entry per interest type, in form order. */
private List<Map<String, Object>> interestRateDetail;
public void setCbsExtensions(final Map<String, Object> cbsExtensions) {
this.cbsExtensions = cbsExtensions;
}
public void setInterestRateDetail(final List<Map<String, Object>> interestRateDetail) {
this.interestRateDetail = interestRateDetail;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,740 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The legacy "Account Type Creation" configuration for a loan product.
*
* Fineract's loan product schema has no home for these fields, and they are specific to this
* deployment, so they hang off {@link LoanProduct} 1:1 instead of widening m_product_loan - an
* upstream change to that table can then never collide with them.
*
* Loan account creation reads this at origination time, which is why these are typed columns rather
* than a JSON blob: callers get a compile-checked getter and reports get a plain column.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_cbs_ext")
public class LoanProductCbsExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/**
* Shared primary key: this and {@link #loanProductId} are the same column, so the association is
* mapped read-only and {@code loanProductId} is the one that writes it. EclipseLink rejects an
* entity with two writable mappings for one field.
*/
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification & legacy header fields (24) ---------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "debit_int_calculation")
private Boolean debitIntCalculation;
@Column(name = "separate_penal_interest")
private Boolean separatePenalInterest;
@Column(name = "credit_int_calculation")
private Boolean creditIntCalculation;
@Column(name = "sub_account_type_balance")
private Boolean subAccountTypeBalance;
@Column(name = "final_int_rounding")
private Boolean finalIntRounding;
@Column(name = "rounding_up_to")
private Integer roundingUpTo;
@Column(name = "account_type_code")
private String accountTypeCode;
@Column(name = "micr_transaction_code")
private String micrTransactionCode;
@Column(name = "reserved_ecs_serial_number")
private String reservedEcsSerialNumber;
@Column(name = "reverse_ecs_number")
private String reverseEcsNumber;
@Column(name = "lock_cheque_info_enabled")
private Boolean lockChequeInfoEnabled;
@Column(name = "lock_page_two_info_enabled")
private Boolean lockPageTwoInfoEnabled;
@Column(name = "computerization_date")
private LocalDate computerizationDate;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "pos_account_type_description")
private String posAccountTypeDescription;
@Column(name = "pfms_account_type")
private String pfmsAccountType;
@Column(name = "renewal_date_add_months")
private Integer renewalDateAddMonths;
@Column(name = "document_renewal_date_add_months")
private Integer documentRenewalDateAddMonths;
@Column(name = "pps_limit")
private BigDecimal ppsLimit;
@Column(name = "is_write_off_account_type")
private Boolean isWriteOffAccountType;
@Column(name = "write_off_account_type_code")
private String writeOffAccountTypeCode;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
// ---- Cheque Book Settings (10) --------------------------------------
@Column(name = "cheque_book_issue_enabled")
private Boolean chequeBookIssueEnabled;
@Column(name = "cheque_validation_required")
private Boolean chequeValidationRequired;
@Column(name = "is_first_cheque_book_free")
private Boolean isFirstChequeBookFree;
@Column(name = "cheque_printing_account_type")
private String chequePrintingAccountType;
@Column(name = "allow_withdrawal_slip_usage")
private Boolean allowWithdrawalSlipUsage;
@Column(name = "maintain_cheque_book_stock_registry")
private Boolean maintainChequeBookStockRegistry;
@Column(name = "free_cheque_books_per_year")
private Integer freeChequeBooksPerYear;
@Column(name = "allow_credit_by_cheque_number")
private Boolean allowCreditByChequeNumber;
@Column(name = "credit_cheque_validation_required")
private Boolean creditChequeValidationRequired;
@Column(name = "grace_days_for_stock_statement")
private Integer graceDaysForStockStatement;
// ---- Additional Product Settings (37) -------------------------------
@Column(name = "account_type_active_for_transactions")
private Boolean accountTypeActiveForTransactions;
@Column(name = "sub_type_activity_enabled")
private Boolean subTypeActivityEnabled;
@Column(name = "account_opening_charges")
private BigDecimal accountOpeningCharges;
@Column(name = "maximum_gold_loan_limit")
private BigDecimal maximumGoldLoanLimit;
@Column(name = "last_account_number_opened")
private String lastAccountNumberOpened;
@Column(name = "customer_termination_allowed")
private Boolean customerTerminationAllowed;
@Column(name = "charges_for_cash_credit_above_limit")
private BigDecimal chargesForCashCreditAboveLimit;
@Column(name = "death_interest_gl_account")
private String deathInterestGLAccount;
@Column(name = "starting_ledger_number")
private Integer startingLedgerNumber;
@Column(name = "ending_ledger_number")
private Integer endingLedgerNumber;
@Column(name = "current_ledger_number")
private Integer currentLedgerNumber;
@Column(name = "remittance_account_number")
private String remittanceAccountNumber;
@Column(name = "is_non_resident_indian_account")
private Boolean isNonResidentIndianAccount;
@Column(name = "debit_clearing_gl_account")
private String debitClearingGLAccount;
@Column(name = "missed_call_alert_service_active")
private Boolean missedCallAlertServiceActive;
@Column(name = "standing_instruction_charge_gl_account")
private String standingInstructionChargeGLAccount;
@Column(name = "standing_instruction_debit_charge_skip_option")
private String standingInstructionDebitChargeSkipOption;
@Column(name = "opening_balance_multiplier_for_remittance_charges")
private BigDecimal openingBalanceMultiplierForRemittanceCharges;
@Column(name = "gold_loan_packet_number_with_year_enabled")
private Boolean goldLoanPacketNumberWithYearEnabled;
@Column(name = "account_move_to_inoperative_enabled")
private Boolean accountMoveToInoperativeEnabled;
@Column(name = "generate_gl_diff_entry_enabled")
private Boolean generateGlDiffEntryEnabled;
@Column(name = "sub_type_wise_gl_balance_generation_enabled")
private Boolean subTypeWiseGlBalanceGenerationEnabled;
@Column(name = "validate_before_deleting_debit_entry_enabled")
private Boolean validateBeforeDeletingDebitEntryEnabled;
@Column(name = "rc_file_concept_enabled")
private Boolean rcFileConceptEnabled;
@Column(name = "account_close_zero_balance_in_open_day_process")
private Boolean accountCloseZeroBalanceInOpenDayProcess;
@Column(name = "account_close_zero_balance_at_subledger_tran_entry")
private Boolean accountCloseZeroBalanceAtSubledgerTranEntry;
@Column(name = "limit_enhancement_against_clearing_screen_display_enabled")
private Boolean limitEnhancementAgainstClearingScreenDisplayEnabled;
@Column(name = "advance_interest_parameter_in_ledger_enabled")
private Boolean advanceInterestParameterInLedgerEnabled;
@Column(name = "vehicle_detail_in_master_enabled")
private Boolean vehicleDetailInMasterEnabled;
@Column(name = "deleted_cheque_issue_enabled")
private Boolean deletedChequeIssueEnabled;
@Column(name = "dp_reduce_in_document_renewal_date_enabled")
private Boolean dpReduceInDocumentRenewalDateEnabled;
@Column(name = "dp_reduce_in_renewal_date_enabled")
private Boolean dpReduceInRenewalDateEnabled;
@Column(name = "auto_close_at_day_end_enabled")
private Boolean autoCloseAtDayEndEnabled;
@Column(name = "loan_rate_from_master_enabled")
private Boolean loanRateFromMasterEnabled;
@Column(name = "cheque_short_name")
private String chequeShortName;
@Column(name = "statement_print_enabled")
private Boolean statementPrintEnabled;
@Column(name = "nesl_code")
private String neslCode;
// ---- Interest Related Parameters (53) -------------------------------
@Column(name = "drawing_power_and_limit_generation_procedure")
private String drawingPowerAndLimitGenerationProcedure;
@Column(name = "advances_interest_calculation_method")
private String advancesInterestCalculationMethod;
@Column(name = "against_interest_calculation_enabled")
private Boolean againstInterestCalculationEnabled;
@Column(name = "day_wise_against_clearing_interest_rounding")
private Boolean dayWiseAgainstClearingInterestRounding;
@Column(name = "interest_calculation_one_fourth_method")
private String interestCalculationOneFourthMethod;
@Column(name = "against_clearing_interest_procedure_type")
private String againstClearingInterestProcedureType;
@Column(name = "consider_debit_closing_amount_for_against_clearing")
private Boolean considerDebitClosingAmountForAgainstClearing;
@Column(name = "interest_rounding_method")
private String interestRoundingMethod;
@Column(name = "penalty_interest_calculation_enabled")
private Boolean penaltyInterestCalculationEnabled;
@Column(name = "penalty_interest_calculation_method")
private String penaltyInterestCalculationMethod;
@Column(name = "penalty_interest_procedure_number")
private String penaltyInterestProcedureNumber;
@Column(name = "overdrawn_interest_calculation_method")
private String overdrawnInterestCalculationMethod;
@Column(name = "calculate_overdrawn_interest_after_renewal")
private Boolean calculateOverdrawnInterestAfterRenewal;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "penalty_interest_date_different_than_regular_interest")
private Boolean penaltyInterestDateDifferentThanRegularInterest;
@Column(name = "fixed_deposit_overdraft_certificate_sort_order")
private String fixedDepositOverdraftCertificateSortOrder;
@Column(name = "use_document_submission_date_for_interest")
private Boolean useDocumentSubmissionDateForInterest;
@Column(name = "loan_management_system_renewal_penalty_active")
private Boolean loanManagementSystemRenewalPenaltyActive;
@Column(name = "minimum_normal_interest_amount")
private BigDecimal minimumNormalInterestAmount;
@Column(name = "minimum_interest_calculation_method")
private String minimumInterestCalculationMethod;
@Column(name = "minimum_against_clearing_interest_amount")
private BigDecimal minimumAgainstClearingInterestAmount;
@Column(name = "limit_increase_upto_interest_amount_in_interest_calculation")
private Boolean limitIncreaseUptoInterestAmountInInterestCalculation;
@Column(name = "minimum_penalty_interest_amount")
private BigDecimal minimumPenaltyInterestAmount;
@Column(name = "days_for_limit_increase_upto_interest_amount")
private Integer daysForLimitIncreaseUptoInterestAmount;
@Column(name = "additional_interest_rate_for_fixed_deposit_overdraft")
private BigDecimal additionalInterestRateForFixedDepositOverdraft;
@Column(name = "consider_dd_payorder_in_against_clearing_interest_calculation")
private Boolean considerDdPayorderInAgainstClearingInterestCalculation;
@Column(name = "grace_days_after_renew_date")
private Integer graceDaysAfterRenewDate;
@Column(name = "apply_undebited_interest_concept_enabled")
private Boolean applyUndebitedInterestConceptEnabled;
@Column(name = "interest_register_print_format_type")
private String interestRegisterPrintFormatType;
@Column(name = "normal_grace_days_after_overdue")
private Integer normalGraceDaysAfterOverdue;
@Column(name = "allow_against_clearing_entry")
private Boolean allowAgainstClearingEntry;
@Column(name = "allow_no_balance_entry")
private Boolean allowNoBalanceEntry;
@Column(name = "fd_od_penalty_interest_combined_with_normal_interest")
private Boolean fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest;
@Column(name = "allow_no_balance_entry_with_password")
private Boolean allowNoBalanceEntryWithPassword;
@Column(name = "undebited_simple_interest_account_wise")
private Boolean undebitedSimpleInterestAccountWise;
@Column(name = "daily_interest_calculation_enabled")
private Boolean dailyInterestCalculationEnabled;
@Column(name = "daily_interest_calculation_start_date")
private String dailyInterestCalculationStartDate;
@Column(name = "non_performing_asset_penal_interest_rate_update")
private Boolean nonPerformingAssetPenalInterestRateUpdate;
@Column(name = "non_performing_asset_penal_interest_rate")
private BigDecimal nonPerformingAssetPenalInterestRate;
@Column(name = "fd_od_penal_interest_rate_update_in_branch_process")
private Boolean fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess;
@Column(name = "add_penal_interest_rate")
private BigDecimal addPenalInterestRate;
@Column(name = "atyp14_simple_interest_enabled")
private Boolean atyp14SimpleInterestEnabled;
@Column(name = "fixed_deposit_overdraft_margin_type")
private String fixedDepositOverdraftMarginType;
@Column(name = "check_master_detail_edited_passing_pending_enabled")
private Boolean checkMasterDetailEditedPassingPendingEnabled;
@Column(name = "credit_debit_interest_calculation_separate_enabled")
private Boolean creditDebitInterestCalculationSeparateEnabled;
@Column(name = "on_time_interest_provision_enabled")
private Boolean onTimeInterestProvisionEnabled;
@Column(name = "check_against_clearing_enabled")
private Boolean checkAgainstClearingEnabled;
@Column(name = "gst_on_penalty_enabled")
private Boolean gstOnPenaltyEnabled;
@Column(name = "penalty_interest_grace_days")
private Integer penaltyInterestGraceDays;
@Column(name = "overdrawn_interest_rate")
private BigDecimal overdrawnInterestRate;
@Column(name = "calculate_daily_limit_enhancement_tod_enabled")
private Boolean calculateDailyLimitEnhancementTodEnabled;
@Column(name = "fixed_deposit_overdraft_multiple_interest_rate_enabled")
private Boolean fixedDepositOverdraftMultipleInterestRateEnabled;
@Column(name = "grace_days_after_overdue_for_moratorium")
private Integer graceDaysAfterOverdueForMoratorium;
// ---- Credit Bureau Settings (CIBIL) (8) ----------------------------
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "cibil_commercial_account_type")
private String cibilCommercialAccountType;
@Column(name = "highmark_account_type")
private String highmarkAccountType;
@Column(name = "highmark_commercial_account_type")
private String highmarkCommercialAccountType;
@Column(name = "experian_account_type")
private String experianAccountType;
@Column(name = "experian_commercial_account_type")
private String experianCommercialAccountType;
@Column(name = "equifax_account_type")
private String equifaxAccountType;
@Column(name = "equifax_commercial_account_type")
private String equifaxCommercialAccountType;
// ---- RBI Compliance & Product Category (6) -------------------------
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "penal_charge_method")
private String penalChargeMethod;
@Column(name = "is_floating_rate")
private Boolean isFloatingRate;
@Column(name = "foreclosure_charges_applicable")
private Boolean foreclosureChargesApplicable;
@Column(name = "key_fact_statement_enabled")
private Boolean keyFactStatementEnabled;
@Column(name = "annual_percentage_rate_calculation_enabled")
private Boolean annualPercentageRateCalculationEnabled;
// ---- Channel & Transaction Limits (23) ------------------------------
@Column(name = "is_mobile_app_enabled")
private Boolean isMobileAppEnabled;
@Column(name = "is_atm_enabled")
private Boolean isAtmEnabled;
@Column(name = "atm_yearly_charge_enabled")
private Boolean atmYearlyChargeEnabled;
@Column(name = "atm_minimum_balance")
private BigDecimal atmMinimumBalance;
@Column(name = "email_active_enabled")
private Boolean emailActiveEnabled;
@Column(name = "is_imps_enabled")
private Boolean isImpsEnabled;
@Column(name = "is_upi_enabled")
private Boolean isUpiEnabled;
@Column(name = "is_pos_enabled")
private Boolean isPosEnabled;
@Column(name = "is_bbps_enabled")
private Boolean isBbpsEnabled;
@Column(name = "inter_bank_limit_per_day")
private BigDecimal interBankLimitPerDay;
@Column(name = "inter_bank_limit_per_transaction")
private BigDecimal interBankLimitPerTransaction;
@Column(name = "neft_rtgs_limit_per_day")
private BigDecimal neftRtgsLimitPerDay;
@Column(name = "neft_rtgs_limit_per_transaction")
private BigDecimal neftRtgsLimitPerTransaction;
@Column(name = "neft_cutoff_limit")
private BigDecimal neftCutoffLimit;
@Column(name = "rtgs_cutoff_limit")
private BigDecimal rtgsCutoffLimit;
@Column(name = "neft_rtgs_minimum_balance")
private BigDecimal neftRtgsMinimumBalance;
@Column(name = "is_charge_required_neft_rtgs_cbs")
private Boolean isChargeRequiredNeftRtgsCbs;
@Column(name = "is_charge_required_neft_rtgs_mobile")
private Boolean isChargeRequiredNeftRtgsMobile;
@Column(name = "imps_limit_per_day")
private BigDecimal impsLimitPerDay;
@Column(name = "imps_limit_per_transaction")
private BigDecimal impsLimitPerTransaction;
@Column(name = "imps_limit_per_month")
private BigDecimal impsLimitPerMonth;
@Column(name = "imps_min_limit")
private BigDecimal impsMinLimit;
@Column(name = "imps_monthly_transaction_count")
private Integer impsMonthlyTransactionCount;
// ---- POS Facility & ATM CSV Settings (10) ---------------------------
@Column(name = "max_per_transaction_limit_atm")
private BigDecimal maxPerTransactionLimitAtm;
@Column(name = "max_day_transaction_limit_atm")
private BigDecimal maxDayTransactionLimitAtm;
@Column(name = "max_per_transaction_limit_pos")
private BigDecimal maxPerTransactionLimitPos;
@Column(name = "max_day_transaction_limit_pos")
private BigDecimal maxDayTransactionLimitPos;
@Column(name = "max_per_transaction_limit_ecom")
private BigDecimal maxPerTransactionLimitEcom;
@Column(name = "max_day_transaction_limit_ecom")
private BigDecimal maxDayTransactionLimitEcom;
@Column(name = "max_per_transaction_limit_upi")
private BigDecimal maxPerTransactionLimitUpi;
@Column(name = "max_day_transaction_limit_upi")
private BigDecimal maxDayTransactionLimitUpi;
@Column(name = "atm_csv_product_code")
private String atmCsvProductCode;
@Column(name = "atm_csv_type_code")
private String atmCsvTypeCode;
// ---- Auto Interest Post Parameters (4) -----------------------------
@Column(name = "auto_interest_post_type")
private String autoInterestPostType;
@Column(name = "auto_interest_post_when")
private String autoInterestPostWhen;
@Column(name = "auto_interest_post_fix_day")
private Integer autoInterestPostFixDay;
@Column(name = "auto_last_interest_date")
private String autoLastInterestDate;
// ---- Foreclosure / Early Account Closure (3) -----------------------
@Column(name = "calculate_early_account_close_charge")
private Boolean calculateEarlyAccountCloseCharge;
@Column(name = "early_account_close_charge")
private BigDecimal earlyAccountCloseCharge;
@Column(name = "early_account_close_period_in_months")
private Integer earlyAccountClosePeriodInMonths;
// ---- Terms step (3) ------------------------------------------------
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_trans_after_due_date")
private Boolean allowDebitTransAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- legacy cheque / standing instruction / minimum balance charges (24)
@Column(name = "cheque_book_charge_enabled")
private Boolean chequeBookChargeEnabled;
@Column(name = "cheque_return_charge_enabled")
private Boolean chequeReturnChargeEnabled;
@Column(name = "ic_charge_debit_enabled")
private Boolean icChargeDebitEnabled;
@Column(name = "cash_bundle_charge_enabled")
private Boolean cashBundleChargeEnabled;
@Column(name = "cash_bundle_charge_with_pan_limit")
private BigDecimal cashBundleChargeWithPanLimit;
@Column(name = "per_cheque_entry_charge")
private BigDecimal perChequeEntryCharge;
@Column(name = "per_cheque_entry_service_charge")
private BigDecimal perChequeEntryServiceCharge;
@Column(name = "per_inward_cheque_entry_charge")
private BigDecimal perInwardChequeEntryCharge;
@Column(name = "per_outward_cheque_entry_charge")
private BigDecimal perOutwardChequeEntryCharge;
@Column(name = "creditor_standing_instruction_fail_charge")
private BigDecimal creditorStandingInstructionFailCharge;
@Column(name = "debtor_standing_instruction_fail_charge")
private BigDecimal debtorStandingInstructionFailCharge;
@Column(name = "minimum_balance_checking_type")
private String minimumBalanceCheckingType;
@Column(name = "auto_minimum_balance_charge")
private Boolean autoMinimumBalanceCharge;
@Column(name = "calculate_daily_minimum_balance_charges")
private Boolean calculateDailyMinimumBalanceCharges;
@Column(name = "minimum_balance_charge_enabled")
private Boolean minimumBalanceChargeEnabled;
@Column(name = "minimum_balance_charge_amount")
private BigDecimal minimumBalanceChargeAmount;
@Column(name = "minimum_balance_charge_for_cheque_issue")
private BigDecimal minimumBalanceChargeForChequeIssue;
@Column(name = "minimum_balance_charge_method")
private String minimumBalanceChargeMethod;
@Column(name = "months_after_min_balance_charge_calculate")
private Integer monthsAfterMinBalanceChargeCalculate;
@Column(name = "for_cheque_issue")
private BigDecimal forChequeIssue;
@Column(name = "for_cheque_issue_avg")
private BigDecimal forChequeIssueAvg;
@Column(name = "for_withdrawal_slip")
private BigDecimal forWithdrawalSlip;
@Column(name = "for_withdrawal_slip_avg")
private BigDecimal forWithdrawalSlipAvg;
@Column(name = "calculate_daily_against_clearing_charge")
private Boolean calculateDailyAgainstClearingCharge;
// ---- custom GL account mappings (14) --------------------------------
@Column(name = "int_receivables_gl_account_id")
private Long intReceivablesGlAccountId;
@Column(name = "int_payable_gl_account_id")
private Long intPayableGlAccountId;
@Column(name = "deaf_int_payable_gl_account_id")
private Long deafIntPayableGlAccountId;
@Column(name = "int_overdue_reserve_account_id")
private Long intOverdueReserveAccountId;
@Column(name = "undebited_interest_account_id")
private Long undebitedInterestAccountId;
@Column(name = "penal_int_cr_gl_account_id")
private Long penalIntCrGlAccountId;
@Column(name = "rd_matured_gl_account_id")
private Long rdMaturedGlAccountId;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "ic_charge_gl_account_id")
private Long icChargeGlAccountId;
@Column(name = "inoperative_gl_account_id")
private Long inoperativeGlAccountId;
@Column(name = "credit_balance_liability_gl_account_id")
private Long creditBalanceLiabilityGlAccountId;
@Column(name = "credit_balance_asset_gl_account_id")
private Long creditBalanceAssetGlAccountId;
@Column(name = "overdue_accounts_gl_account_id")
private Long overdueAccountsGlAccountId;
@Column(name = "rebate_gl_account_id")
private Long rebateGlAccountId;
public LoanProductCbsExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,20 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductCbsExtRepository extends JpaRepository<LoanProductCbsExt, Long> {
/**
* A product created before this table existed - or one saved without any legacy field filled in - has
* no row here, so callers must handle the empty case rather than assuming one exists.
*/
Optional<LoanProductCbsExt> findByLoanProductId(Long loanProductId);
}
@@ -0,0 +1,82 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of the legacy "Interest Rate Detail" grid on the loan product form.
*
* The grid is a fixed four rows - Normal Debit, Normal Credit, Against Clearing and Penalty - each
* carrying a Normal and a Special min/max rate plus the period they apply over. It is a child table
* rather than twenty-four columns on {@link LoanProductCbsExt} because the interest type reads far
* better as data than as part of a column name: against_clearing_special_max_rate and its siblings
* are almost unreadable, and any new interest type would mean another migration.
*
* The rows are fixed today, so {@link #INTEREST_TYPES} is the full set and the table carries a unique
* constraint on (product, interest type) - a replayed save updates in place instead of doubling the
* grid.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_cbs_interest_rate", uniqueConstraints = {
@UniqueConstraint(columnNames = { "loan_product_id", "interest_type" }, name = "mplcir_unq_product_interest_type") })
public class LoanProductCbsInterestRate extends AbstractPersistableCustom<Long> {
/** The grid's rows, in the order the form shows them. */
public static final String[] INTEREST_TYPES = { "Normal Debit", "Normal Credit", "Against Clearing", "Penalty" };
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "loan_product_id", nullable = false)
private LoanProduct loanProduct;
@Column(name = "interest_type", nullable = false, length = 30)
private String interestType;
@Column(name = "calculation")
private Boolean calculation;
@Column(name = "normal_min_rate", precision = 9, scale = 6)
private BigDecimal normalMinRate;
@Column(name = "normal_max_rate", precision = 9, scale = 6)
private BigDecimal normalMaxRate;
@Column(name = "special_min_rate", precision = 9, scale = 6)
private BigDecimal specialMinRate;
@Column(name = "special_max_rate", precision = 9, scale = 6)
private BigDecimal specialMaxRate;
@Column(name = "period", length = 20)
private String period;
public LoanProductCbsInterestRate(final LoanProduct loanProduct, final String interestType) {
this.loanProduct = loanProduct;
this.interestType = interestType;
}
/** True when the officer left this row untouched, so it need not be persisted. */
public boolean isEmpty() {
return !Boolean.TRUE.equals(this.calculation) && this.normalMinRate == null && this.normalMaxRate == null
&& this.specialMinRate == null && this.specialMaxRate == null && (this.period == null || this.period.isBlank());
}
}
@@ -0,0 +1,18 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductCbsInterestRateRepository extends JpaRepository<LoanProductCbsInterestRate, Long> {
List<LoanProductCbsInterestRate> findByLoanProductIdOrderById(Long loanProductId);
LoanProductCbsInterestRate findByLoanProductIdAndInterestType(Long loanProductId, String interestType);
}
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx
import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData;
import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductCbsExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
@@ -171,6 +172,7 @@ public class LoanProductsApiResource {
private final ConfigurationDomainService configurationDomainService;
private final DelinquencyReadPlatformService delinquencyReadPlatformService;
private final CodeValueReadPlatformService codeValueReadPlatformService;
private final LoanProductCbsExtService loanProductCbsExtService;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@@ -384,6 +386,13 @@ public class LoanProductsApiResource {
if (settings.isTemplate()) {
loanProduct = handleTemplate(loanProduct);
}
// Legacy "Account Type Creation" fields, so the edit form can be repopulated with what was saved.
// Set here rather than in the read service because handleTemplate above returns a fresh
// LoanProductData built by a copy constructor, which would not carry these across.
loanProduct.setCbsExtensions(this.loanProductCbsExtService.retrieveAsMap(productId));
loanProduct.setInterestRateDetail(this.loanProductCbsExtService.retrieveInterestRateDetail(productId));
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
}
@@ -77,6 +77,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMe
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
import org.apache.fineract.portfolio.loanproduct.domain.RecalculationFrequencyType;
import org.apache.fineract.portfolio.loanproduct.exception.EqualAmortizationUnsupportedFeatureException;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductCbsExtService;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@@ -212,6 +213,12 @@ public final class LoanProductDataValidator {
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
));
static {
// The legacy "Account Type Creation" fields are persisted by LoanProductCbsExtService, which owns
// the authoritative list of their names -- added here rather than restated so the two cannot drift.
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductCbsExtService.CBS_PARAMETERS));
}
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
@@ -235,8 +242,14 @@ public final class LoanProductDataValidator {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
// TEMPORARILY DISABLED while the legacy "Account Type Creation" fields are being wired through this
// endpoint. SUPPORTED_PARAMETERS above already lists them, so re-enabling this is a one-line revert.
//
// Note what is lost until then: an unrecognised parameter is now accepted and silently ignored
// instead of returning 400. A misspelt field name will therefore look like it saved and simply not
// appear, so check the column actually changed rather than trusting a 200.
// final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
// this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource(LOANPRODUCT);
@@ -1336,8 +1349,9 @@ public final class LoanProductDataValidator {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
// TEMPORARILY DISABLED -- see the same note on validateForCreate above.
// final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
// this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource(LOANPRODUCT);
@@ -97,6 +97,7 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
private final LoanProductAssembler loanProductAssembler;
private final LoanProductUpdateUtil loanProductUpdateUtil;
private final LoanProductCbsExtService loanProductCbsExtService;
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
@@ -141,6 +142,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(loanProduct);
// Legacy "Account Type Creation" fields, persisted in their own satellite table. Called after the
// flush because the satellite row is keyed on the product's generated id. A request carrying none
// of those parameters writes nothing.
this.loanProductCbsExtService.saveOrUpdate(loanProduct, command);
// save accounting mappings
this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command);
// check if the office specific products are enabled. If yes, then
@@ -303,6 +309,14 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(product);
}
// Legacy "Account Type Creation" fields. Deliberately outside the changes check above: those
// changes only track the standard product columns, so an edit that touched nothing but a legacy
// field leaves the map empty and would otherwise be dropped.
//
// What it changed is folded into the same changes map, so the audit trail shows a per-field
// diff for these exactly as it does for the standard ones rather than an empty change set.
changes.putAll(this.loanProductCbsExtService.saveOrUpdate(product, command));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loanProductId) //
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository;
import org.apache.fineract.portfolio.loanproduct.serialization.LoanProductDataValidator;
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductCbsExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
DelinquencyBucketRepository delinquencyBucketRepository,
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
LoanProductCbsExtService loanProductCbsExtService) {
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
loanProductAssembler, loanProductUpdateUtil);
loanProductAssembler, loanProductUpdateUtil, loanProductCbsExtService);
}
}
@@ -57,14 +57,23 @@ public class MobileLookupServiceImpl implements MobileLookupService {
"9752123456", "9752345678", "9752987654"
);
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
private final String baseUrl;
private final boolean enabled;
private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository;
private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl;
this.enabled = enabled;
this.restTemplate = new RestTemplate();
this.logRepository = logRepository;
this.jdbcTemplate = jdbcTemplate;
@@ -103,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return MobileLookupResult.clean(logId, trimmed);
}
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
if (isDemoDisconnectedNumber(trimmed)) {
LOG.info("Demo disconnected number detected: {}", trimmed);
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
}
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
"active", "Demo DIP suspected number", "DEMO");
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
}
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
// CLEAN result so it can never be mistaken for one in an audit trail.
if (!enabled) {
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
return MobileLookupResult.clean(logId, trimmed);
}
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
LOG.info("Calling DIP/MNRL lookup API: {}", url);
@@ -402,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return false;
}
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private String getStringOrNull(JsonObject json, String key) {
JsonElement el = json.get(key);
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
@@ -18,6 +18,11 @@
*/
package org.apache.fineract.portfolio.savings.api;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
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.documentmanagement.api.DocumentApiResource;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
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.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Path("/v1/currentaccounts")
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
private final DocumentApiResource documentApiResource;
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
@GET
@Path("template")
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
return this.toApiJsonSerializer.serialize(result);
}
// ── Documents ────────────────────────────────────────────────────────────
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
// is matched first for anything under {accountId}, and it has no "documents" method, so the
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
// DocumentApiResource so current accounts get identical document-management behaviour.
@GET
@Path("{accountId}/documents")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
}
@GET
@Path("{accountId}/documents/{documentId}/attachment")
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Download a document attached to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
@POST
@Path("{accountId}/documents")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Attach a document to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
description);
}
@DELETE
@Path("{accountId}/documents/{documentId}")
@Operation(summary = "Remove a document attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
@PathParam("documentId") final Long documentId) {
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
}
@@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -105,11 +106,14 @@ public class DepositAccountDataValidator {
private final FromJsonHelper fromApiJsonHelper;
private final DepositProductDataValidator productDataValidator;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
@Autowired
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) {
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator,
final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
this.fromApiJsonHelper = fromApiJsonHelper;
this.productDataValidator = productDataValidator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
}
public void validateFixedDepositForSubmit(final String json) {
@@ -351,10 +355,31 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
}
@@ -493,10 +518,31 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
@@ -555,14 +555,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
List<SavingsAccountTransaction> transactions = savingsAccount.getTransactions();
int size = transactions.size();
for (int i = size - 1;; i--) {
SavingsAccountTransaction transaction = transactions.get(i);
if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) {
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
// so that withdrawal must be added to existingTransactionIds here or the caller's own
// postJournalEntries() re-derives and re-posts it a second time. The previous implementation
// found it by walking the transaction list backwards from the end while the type stayed
// withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on
// a fresh fetch, not after an in-memory append, so a same-day interest posting or any other
// ordering quirk could put the new withdrawal anywhere in the list and break the walk
// silently. Scanning every transaction and checking against the original snapshot is correct
// regardless of collection order.
for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) {
if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) {
existingTransactionIds.add(transaction.getId());
} else {
break;
}
}
}
@@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount());
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of the
// deposit amount configured on the account's term/pre-closure details.
return Money.zero(this.currency);
}
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance);
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
}
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
@@ -19,7 +19,6 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
// Account Facility Config
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) {
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName);
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) {
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
if (facilityConfig == null) {
facilityConfig = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(facilityConfig);
}
facilityConfig.updateFrom(configEl.getAsJsonObject());
}
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
// Joint holders
if (element.getAsJsonObject().has(jointHoldersParamName)) {
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
}
// Minimum required balance validation
// Minimum required balance is not enforced -- a withdrawal must never be blocked for
// taking the account below its configured minimum, only for exceeding the available
// balance (checked above).
final BigDecimal newBalance = currentBalance.subtract(amount);
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
throw new GeneralPlatformDomainRuleException(
"error.msg.current.account.min.required.balance.breached",
"Withdrawal of " + amount + " would breach the minimum required balance of "
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
accountId);
}
}
final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
}
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig"
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig");
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig");
if (fcJson != null) {
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
// Joint holders
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final JsonObject json = command.parsedJson().getAsJsonObject();
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// Validate minimum opening balance before activation
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
// intentionally ignored
}
}
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
&& openingBalanceValue.compareTo(minRequired) < 0) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
"validation.msg.currentaccount.openingBalance.less.than.min.required",
openingBalanceValue, minRequired)));
}
account.activate(activatedOnDate, user);
applyFacilityChargesOnActivation(account);
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
// Opening balance deposit transaction
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
try {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
deposit.setRunningBalanceDerived(openingBalanceValue);
account.setTotalDepositsDerived(openingBalanceValue);
currentAccountTransactionRepository.saveAndFlush(deposit);
currentBalance = openingBalanceValue;
depositTxns.add(deposit);
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
} catch (Exception e) {
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
}
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
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);
// Activation posts no transaction of its own -- no opening-balance deposit, no
// digital-facility charges applied or collected, regardless of what the account's facility
// config or product defaults say. Funding and any facility charges happen strictly
// afterwards, through separate transactions a clerk processes once the account is already
// active.
currentAccountRepository.saveAndFlush(account);
// Post GL journal entries — deposits in batch, each charge individually
postGlEntries(account, depositTxns, chargeEntries);
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
}
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
// Use account-level facility config — reflects what was selected at account opening time
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
account.getFacilityConfig();
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
account.getId(), config != null ? "FOUND" : "NULL");
if (config == null) {
// Fallback: use product-level facility config
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
config = currentProductRepository.findById(account.getProductId())
.map(p -> {
final var pc = p.getFacilityConfig();
if (pc == null) {
log.info("[FacilityCharge] Product has no facility config either");
return null;
}
// Map product config to account config fields inline
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
return ac;
}).orElse(null);
}
if (config == null) {
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
return;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
// 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.
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
}
// Wrapper to carry a charge transaction together with its charge entity for GL posting
private record ChargeGlEntry(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
/**
* 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.
* posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
* activation itself no longer collects any charges. Returns the resulting balance.
*/
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
return;
}
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
return;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
return;
}
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
account.getCharges().add(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
.createFromFacility(account, charge));
}
}
@@ -554,7 +554,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
accountChart, null, null);
// Re-apply CBS product fields lost during factory method object creation
@@ -626,7 +626,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
}
@@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (portfolioAccountData == null) {
final PaymentDetail paymentDetail = null;
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), paymentDetail);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
// handleFDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes, e.g. an activation charge payment) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final SavingsAccount fromSavingsAccount = null;
boolean isRegularTransaction = false;
@@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
if (amountForDeposit.isGreaterThanZero()) {
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
} else {
// No deposit has actually landed on the account yet -- project maturity using the
// configured deposit amount (the same approach used for the pre-activation preview and
// for reinvested FDs) instead of the account's real, still-empty transaction history,
// which would otherwise price in zero interest.
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
@@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
.retriveSavingsLinkedAssociation(savingsId);
if (portfolioAccountData == null) {
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), null, isRegularTransaction);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
// handleRDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final boolean isExceptionForBalanceCheck = false;
final SavingsAccount fromSavingsAccount = null;
@@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail);
// The account may have been activated with no funds yet (opening deposit is now a separate,
// later step) -- in that case its maturity amount was only ever projected off the configured
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
// maturity date/amount off the account's real transaction history so it reflects reality.
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT");
@@ -1359,6 +1393,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
// Every maturity-instruction branch takes the balance out of this account through
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
// entries itself. existingTransactionIds was snapshotted at the top of this method --
// before that withdrawal existed -- so the postJournalEntries() call at the end would
// treat it as new and post it a second time. Interest postings created by
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
// out of the snapshot so that final call can post them exactly once.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
Money amountForDeposit = account.activateWithBalance();
@@ -1387,6 +1430,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
}
/**
* Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a
* subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a
* maturity closure, whose withdrawal leg posts its own journal entries.
*/
private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set<Long> existingTransactionIds) {
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) {
existingTransactionIds.add(transaction.getId());
}
}
}
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds) {
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
// 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
// GL journal entries for those charge transactions (collectCharges() only books them
// against the account itself; it does not touch the general ledger).
if (!newlyEnabledFacilityTypes.isEmpty()) {
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
existingReversedTransactionIds, false);
}
// 3) Apply the facility-config toggles to the real row.
@@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Validate minimum opening balance
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
if (command.parameterExists("openingBalance")) {
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
if (ob != null) openingBalanceForValidation = ob;
}
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
final List<ApiParameterError> errors = new ArrayList<>();
errors.add(ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
openingBalanceForValidation, minRequiredOpening));
throw new PlatformApiDataValidationException(errors);
}
// Minimum required opening balance is not enforced -- activation must never be blocked
// for not meeting it; the account can be funded afterwards by whatever deposit the
// customer brings (cash or a transfer), on their own timeline.
checkClientOrGroupActive(account);
final Set<Long> existingTransactionIds = new HashSet<>();
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
// Auto-apply facility charges based on product facility config
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
facilityChargeAutoApplyService.collectActivationCharges(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Activation posts no transaction of its own -- digital-facility charges are no longer
// auto-applied and auto-collected here. Whatever facilities are turned on later get
// charged (if at all) through the normal charge/collection flow at that time, not as a
// side effect of activation.
}
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
}
@Override
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
}
@Override
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
@@ -1690,6 +1676,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusInactive(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects
// pending-inactive ids from a query that requires status = ACTIVE, but the account can close
// through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting
// off the full transaction list at that point re-posts entries that closure already posted.
if (!account.isActive()) {
log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -1701,6 +1696,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusDormant(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
if (!account.isActive()) {
log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
account.setSubStatusDormant();
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@@ -1708,6 +1708,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void escheat(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an
// account can legitimately close through another path (e.g. deposit maturity) in the gap
// between that selection and this call. escheat() unconditionally forces status/sub-status
// to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so
// running it against an already-closed account both mislabels the closure reason and
// re-posts journal entries for transactions that were already posted by the real closure.
if (!account.isActive()) {
log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -463,8 +463,7 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
DenominationBreakdownWriter denominationBreakdownWriter) {
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -472,7 +471,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
errorHandler, denominationBreakdownWriter);
}
@Bean
@@ -688,6 +688,13 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# │ DIP/MNRL provider. Do not ship the mock. │
# └──────────────────────────────────────────────────────────────────────────┘
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
# Set to false to skip the DIP/MNRL check entirely (no HTTP call at all, no need to run the mock
# service) -- every number is then logged as CLEAN but stamped "LOOKUP_DISABLED_BY_CONFIG" so it is
# never mistaken for a real pass.
# Currently OFF by default for local work so nobody needs the mock service running. Set
# FINERACT_MOBILE_LOOKUP_ENABLED=true (or flip the default below) before UAT/production, where the
# check must actually run.
fineract.mobile-lookup.enabled=${FINERACT_MOBILE_LOOKUP_ENABLED:false}
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
@@ -537,4 +537,5 @@
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_create_loan_product_cbs_ext.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,377 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The legacy "Account Type Creation" screen carries a large block of product configuration that
Fineract's own loan product schema has no home for - cheque book rules, channel and transaction
limits, credit bureau account types, the older interest parameters, and a set of extra GL
mappings. Those fields were being collected by the create/edit form and then dropped before the
request was sent, so nothing survived a page reload.
They live in a satellite table keyed 1:1 on the loan product rather than as more columns on
m_product_loan: the set is specific to this deployment, and keeping it separate means an upstream
Fineract change to m_product_loan will never collide with it. Typed columns rather than a JSON
blob because loan account creation reads these at origination time, so they need to be queryable
and compile-checked, not string-keyed. -->
<changeSet id="0470_create_loan_product_cbs_ext" author="CBS" runOnChange="false">
<createTable tableName="m_product_loan_cbs_ext">
<column name="loan_product_id" type="BIGINT">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- classification & legacy header fields (24) -->
<column name="product_type_group" type="VARCHAR(20)"/>
<column name="product_type_sub_group" type="VARCHAR(30)"/>
<column name="debit_int_calculation" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="separate_penal_interest" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="credit_int_calculation" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="sub_account_type_balance" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="final_int_rounding" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="rounding_up_to" type="INTEGER"/>
<column name="account_type_code" type="VARCHAR(10)"/>
<column name="micr_transaction_code" type="VARCHAR(100)"/>
<column name="reserved_ecs_serial_number" type="VARCHAR(100)"/>
<column name="reverse_ecs_number" type="VARCHAR(100)"/>
<column name="lock_cheque_info_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="lock_page_two_info_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="computerization_date" type="DATE"/>
<column name="cheque_print_description" type="VARCHAR(100)"/>
<column name="pos_account_type_description" type="VARCHAR(100)"/>
<column name="pfms_account_type" type="VARCHAR(100)"/>
<column name="renewal_date_add_months" type="INTEGER"/>
<column name="document_renewal_date_add_months" type="INTEGER"/>
<column name="pps_limit" type="DECIMAL(19,6)"/>
<column name="is_write_off_account_type" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="write_off_account_type_code" type="VARCHAR(100)"/>
<column name="renewal_grace_days" type="INTEGER"/>
<!-- Cheque Book Settings (10) -->
<column name="cheque_book_issue_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cheque_validation_required" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_first_cheque_book_free" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cheque_printing_account_type" type="VARCHAR(30)"/>
<column name="allow_withdrawal_slip_usage" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="maintain_cheque_book_stock_registry" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="free_cheque_books_per_year" type="DECIMAL(19,6)"/>
<column name="allow_credit_by_cheque_number" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="credit_cheque_validation_required" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="grace_days_for_stock_statement" type="INTEGER"/>
<!-- Additional Product Settings (37) -->
<column name="account_type_active_for_transactions" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="sub_type_activity_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="account_opening_charges" type="INTEGER"/>
<column name="maximum_gold_loan_limit" type="DECIMAL(19,6)"/>
<column name="last_account_number_opened" type="VARCHAR(100)"/>
<column name="customer_termination_allowed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="charges_for_cash_credit_above_limit" type="DECIMAL(19,6)"/>
<column name="death_interest_gl_account" type="VARCHAR(100)"/>
<column name="starting_ledger_number" type="INTEGER"/>
<column name="ending_ledger_number" type="INTEGER"/>
<column name="current_ledger_number" type="INTEGER"/>
<column name="remittance_account_number" type="VARCHAR(100)"/>
<column name="is_non_resident_indian_account" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="debit_clearing_gl_account" type="VARCHAR(100)"/>
<column name="missed_call_alert_service_active" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="standing_instruction_charge_gl_account" type="VARCHAR(100)"/>
<column name="standing_instruction_debit_charge_skip_option" type="VARCHAR(50)"/>
<column name="opening_balance_multiplier_for_remittance_charges" type="DECIMAL(19,6)"/>
<column name="gold_loan_packet_number_with_year_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="account_move_to_inoperative_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="generate_gl_diff_entry_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="sub_type_wise_gl_balance_generation_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="validate_before_deleting_debit_entry_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="rc_file_concept_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="account_close_zero_balance_in_open_day_process" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="account_close_zero_balance_at_subledger_tran_entry" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="limit_enhancement_against_clearing_screen_display_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="advance_interest_parameter_in_ledger_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="vehicle_detail_in_master_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="deleted_cheque_issue_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="dp_reduce_in_document_renewal_date_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="dp_reduce_in_renewal_date_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="auto_close_at_day_end_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="loan_rate_from_master_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cheque_short_name" type="VARCHAR(100)"/>
<column name="statement_print_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="nesl_code" type="VARCHAR(100)"/>
<!-- Interest Related Parameters (53) -->
<column name="drawing_power_and_limit_generation_procedure" type="VARCHAR(100)"/>
<column name="advances_interest_calculation_method" type="VARCHAR(100)"/>
<column name="against_interest_calculation_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="day_wise_against_clearing_interest_rounding" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="interest_calculation_one_fourth_method" type="VARCHAR(20)"/>
<column name="against_clearing_interest_procedure_type" type="VARCHAR(50)"/>
<column name="consider_debit_closing_amount_for_against_clearing" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="interest_rounding_method" type="VARCHAR(30)"/>
<column name="penalty_interest_calculation_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="penalty_interest_calculation_method" type="VARCHAR(30)"/>
<column name="penalty_interest_procedure_number" type="VARCHAR(100)"/>
<column name="overdrawn_interest_calculation_method" type="VARCHAR(50)"/>
<column name="calculate_overdrawn_interest_after_renewal" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="interest_on_credit_balance" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="penalty_interest_date_different_than_regular_interest" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="fixed_deposit_overdraft_certificate_sort_order" type="VARCHAR(50)"/>
<column name="use_document_submission_date_for_interest" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="loan_management_system_renewal_penalty_active" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="minimum_normal_interest_amount" type="DECIMAL(19,6)"/>
<column name="minimum_interest_calculation_method" type="VARCHAR(50)"/>
<column name="minimum_against_clearing_interest_amount" type="DECIMAL(19,6)"/>
<column name="limit_increase_upto_interest_amount_in_interest_calculation" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="minimum_penalty_interest_amount" type="DECIMAL(19,6)"/>
<column name="days_for_limit_increase_upto_interest_amount" type="INTEGER"/>
<column name="additional_interest_rate_for_fixed_deposit_overdraft" type="DECIMAL(19,6)"/>
<column name="consider_dd_payorder_in_against_clearing_interest_calculation" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="grace_days_after_renew_date" type="INTEGER"/>
<column name="apply_undebited_interest_concept_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="interest_register_print_format_type" type="VARCHAR(50)"/>
<column name="normal_grace_days_after_overdue" type="INTEGER"/>
<column name="allow_against_clearing_entry" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="allow_no_balance_entry" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="fd_od_penalty_interest_combined_with_normal_interest" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="allow_no_balance_entry_with_password" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="undebited_simple_interest_account_wise" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="daily_interest_calculation_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="daily_interest_calculation_start_date" type="VARCHAR(100)"/>
<column name="non_performing_asset_penal_interest_rate_update" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="non_performing_asset_penal_interest_rate" type="DECIMAL(19,6)"/>
<column name="fd_od_penal_interest_rate_update_in_branch_process" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="add_penal_interest_rate" type="DECIMAL(19,6)"/>
<column name="atyp14_simple_interest_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="fixed_deposit_overdraft_margin_type" type="VARCHAR(100)"/>
<column name="check_master_detail_edited_passing_pending_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="credit_debit_interest_calculation_separate_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="on_time_interest_provision_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="check_against_clearing_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="gst_on_penalty_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="penalty_interest_grace_days" type="INTEGER"/>
<column name="overdrawn_interest_rate" type="DECIMAL(19,6)"/>
<column name="calculate_daily_limit_enhancement_tod_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="fixed_deposit_overdraft_multiple_interest_rate_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="grace_days_after_overdue_for_moratorium" type="INTEGER"/>
<!-- Credit Bureau Settings (CIBIL) (8) -->
<column name="cibil_account_type" type="VARCHAR(100)"/>
<column name="cibil_commercial_account_type" type="VARCHAR(100)"/>
<column name="highmark_account_type" type="VARCHAR(100)"/>
<column name="highmark_commercial_account_type" type="VARCHAR(100)"/>
<column name="experian_account_type" type="VARCHAR(100)"/>
<column name="experian_commercial_account_type" type="VARCHAR(100)"/>
<column name="equifax_account_type" type="VARCHAR(100)"/>
<column name="equifax_commercial_account_type" type="VARCHAR(100)"/>
<!-- RBI Compliance & Product Category (6) -->
<column name="loan_product_category" type="VARCHAR(30)"/>
<column name="penal_charge_method" type="VARCHAR(50)"/>
<column name="is_floating_rate" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="foreclosure_charges_applicable" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="key_fact_statement_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="annual_percentage_rate_calculation_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<!-- Channel & Transaction Limits (23) -->
<column name="is_mobile_app_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_atm_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="atm_yearly_charge_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="atm_minimum_balance" type="DECIMAL(19,6)"/>
<column name="email_active_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_imps_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_upi_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_pos_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_bbps_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="inter_bank_limit_per_day" type="DECIMAL(19,6)"/>
<column name="inter_bank_limit_per_transaction" type="DECIMAL(19,6)"/>
<column name="neft_rtgs_limit_per_day" type="DECIMAL(19,6)"/>
<column name="neft_rtgs_limit_per_transaction" type="DECIMAL(19,6)"/>
<column name="neft_cutoff_limit" type="DECIMAL(19,6)"/>
<column name="rtgs_cutoff_limit" type="DECIMAL(19,6)"/>
<column name="neft_rtgs_minimum_balance" type="DECIMAL(19,6)"/>
<column name="is_charge_required_neft_rtgs_cbs" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="is_charge_required_neft_rtgs_mobile" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="imps_limit_per_day" type="DECIMAL(19,6)"/>
<column name="imps_limit_per_transaction" type="DECIMAL(19,6)"/>
<column name="imps_limit_per_month" type="DECIMAL(19,6)"/>
<column name="imps_min_limit" type="DECIMAL(19,6)"/>
<column name="imps_monthly_transaction_count" type="INTEGER"/>
<!-- POS Facility & ATM CSV Settings (10) -->
<column name="max_per_transaction_limit_atm" type="DECIMAL(19,6)"/>
<column name="max_day_transaction_limit_atm" type="DECIMAL(19,6)"/>
<column name="max_per_transaction_limit_pos" type="DECIMAL(19,6)"/>
<column name="max_day_transaction_limit_pos" type="DECIMAL(19,6)"/>
<column name="max_per_transaction_limit_ecom" type="DECIMAL(19,6)"/>
<column name="max_day_transaction_limit_ecom" type="DECIMAL(19,6)"/>
<column name="max_per_transaction_limit_upi" type="DECIMAL(19,6)"/>
<column name="max_day_transaction_limit_upi" type="DECIMAL(19,6)"/>
<column name="atm_csv_product_code" type="VARCHAR(100)"/>
<column name="atm_csv_type_code" type="VARCHAR(100)"/>
<!-- Auto Interest Post Parameters (4) -->
<column name="auto_interest_post_type" type="VARCHAR(30)"/>
<column name="auto_interest_post_when" type="VARCHAR(20)"/>
<column name="auto_interest_post_fix_day" type="INTEGER"/>
<column name="auto_last_interest_date" type="VARCHAR(100)"/>
<!-- Foreclosure / Early Account Closure (3) -->
<column name="calculate_early_account_close_charge" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="early_account_close_charge" type="INTEGER"/>
<column name="early_account_close_period_in_months" type="INTEGER"/>
<!-- Terms step (3) -->
<column name="debit_balance_allow" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="allow_debit_trans_after_due_date" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="branch_level_account_type" type="BOOLEAN" defaultValueBoolean="false"/>
<!-- legacy cheque / standing instruction / minimum balance charges (24) -->
<column name="cheque_book_charge_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cheque_return_charge_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="ic_charge_debit_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cash_bundle_charge_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="cash_bundle_charge_with_pan_limit" type="DECIMAL(19,6)"/>
<column name="per_cheque_entry_charge" type="DECIMAL(19,6)"/>
<column name="per_cheque_entry_service_charge" type="DECIMAL(19,6)"/>
<column name="per_inward_cheque_entry_charge" type="DECIMAL(19,6)"/>
<column name="per_outward_cheque_entry_charge" type="DECIMAL(19,6)"/>
<column name="creditor_standing_instruction_fail_charge" type="DECIMAL(19,6)"/>
<column name="debtor_standing_instruction_fail_charge" type="DECIMAL(19,6)"/>
<column name="minimum_balance_checking_type" type="VARCHAR(20)"/>
<column name="auto_minimum_balance_charge" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="calculate_daily_minimum_balance_charges" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="minimum_balance_charge_enabled" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="minimum_balance_charge_amount" type="DECIMAL(19,6)"/>
<column name="minimum_balance_charge_for_cheque_issue" type="DECIMAL(19,6)"/>
<column name="minimum_balance_charge_method" type="VARCHAR(20)"/>
<column name="months_after_min_balance_charge_calculate" type="INTEGER"/>
<column name="for_cheque_issue" type="DECIMAL(19,6)"/>
<column name="for_cheque_issue_avg" type="DECIMAL(19,6)"/>
<column name="for_withdrawal_slip" type="DECIMAL(19,6)"/>
<column name="for_withdrawal_slip_avg" type="DECIMAL(19,6)"/>
<column name="calculate_daily_against_clearing_charge" type="BOOLEAN" defaultValueBoolean="false"/>
<!-- custom GL account mappings (14) -->
<column name="int_receivables_gl_account_id" type="BIGINT"/>
<column name="int_payable_gl_account_id" type="BIGINT"/>
<column name="deaf_int_payable_gl_account_id" type="BIGINT"/>
<column name="int_overdue_reserve_account_id" type="BIGINT"/>
<column name="undebited_interest_account_id" type="BIGINT"/>
<column name="penal_int_cr_gl_account_id" type="BIGINT"/>
<column name="rd_matured_gl_account_id" type="BIGINT"/>
<column name="gl_account_id" type="BIGINT"/>
<column name="ic_charge_gl_account_id" type="BIGINT"/>
<column name="inoperative_gl_account_id" type="BIGINT"/>
<column name="credit_balance_liability_gl_account_id" type="BIGINT"/>
<column name="credit_balance_asset_gl_account_id" type="BIGINT"/>
<column name="overdue_accounts_gl_account_id" type="BIGINT"/>
<column name="rebate_gl_account_id" type="BIGINT"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="loan_product_id"
constraintName="fk_mplce_loan_product"
referencedTableName="m_product_loan" referencedColumnNames="id"
onDelete="CASCADE"/>
</changeSet>
<!-- The extra GL mappings hold real account ids, so they are constrained rather than left as loose
numbers: deleting a GL account that a product still points at should fail, not leave the product
holding a dangling id. -->
<changeSet id="0470_loan_product_cbs_ext_gl_fks" author="CBS" runOnChange="false">
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="int_receivables_gl_account_id"
constraintName="fk_mplce_int_receivables"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="int_payable_gl_account_id"
constraintName="fk_mplce_int_payable"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="deaf_int_payable_gl_account_id"
constraintName="fk_mplce_deaf_int_payable"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="int_overdue_reserve_account_id"
constraintName="fk_mplce_int_overdue_reserve"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="undebited_interest_account_id"
constraintName="fk_mplce_undebited_interest"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="penal_int_cr_gl_account_id"
constraintName="fk_mplce_penal_int_cr"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="rd_matured_gl_account_id"
constraintName="fk_mplce_rd_matured"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="gl_account_id"
constraintName="fk_mplce_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="ic_charge_gl_account_id"
constraintName="fk_mplce_ic_charge"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="inoperative_gl_account_id"
constraintName="fk_mplce_inoperative"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="credit_balance_liability_gl_account_id"
constraintName="fk_mplce_credit_balance_liability"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="credit_balance_asset_gl_account_id"
constraintName="fk_mplce_credit_balance_asset"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="overdue_accounts_gl_account_id"
constraintName="fk_mplce_overdue_accounts"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_ext" baseColumnNames="rebate_gl_account_id"
constraintName="fk_mplce_rebate"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</changeSet>
<!-- The legacy Interest Rate Detail grid: four fixed interest types, each with a Normal and a Special
min/max rate plus a period. A child table rather than twenty-four flat columns because the
interest type reads far better as data than as part of a column name
(against_clearing_special_max_rate and friends). -->
<changeSet id="0470_create_loan_product_cbs_interest_rate" author="CBS" runOnChange="false">
<createTable tableName="m_product_loan_cbs_interest_rate">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="loan_product_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Normal Debit | Normal Credit | Against Clearing | Penalty -->
<column name="interest_type" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="calculation" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="normal_min_rate" type="DECIMAL(9,6)"/>
<column name="normal_max_rate" type="DECIMAL(9,6)"/>
<column name="special_min_rate" type="DECIMAL(9,6)"/>
<column name="special_max_rate" type="DECIMAL(9,6)"/>
<!-- DAILY | MONTHLY | QUARTERLY | HALF YEARLY | YEARLY -->
<column name="period" type="VARCHAR(20)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_product_loan_cbs_interest_rate" baseColumnNames="loan_product_id"
constraintName="fk_mplcir_loan_product"
referencedTableName="m_product_loan" referencedColumnNames="id"
onDelete="CASCADE"/>
<!-- One row per interest type per product; the grid is fixed, so this also stops a replayed
save from doubling the rows. -->
<addUniqueConstraint tableName="m_product_loan_cbs_interest_rate"
columnNames="loan_product_id, interest_type"
constraintName="mplcir_unq_product_interest_type"/>
</changeSet>
<!-- Both of these are money, not counts, and were created as INTEGER by mistake - a charge of 250.75
was rejected outright. Kept as a follow-on changeset rather than a correction to the two above,
which have already run and whose checksums must not change. -->
<changeSet id="0470_fix_charge_column_types" author="CBS" runOnChange="false">
<modifyDataType tableName="m_product_loan_cbs_ext" columnName="account_opening_charges"
newDataType="DECIMAL(19,6)"/>
<modifyDataType tableName="m_product_loan_cbs_ext" columnName="early_account_close_charge"
newDataType="DECIMAL(19,6)"/>
</changeSet>
<!-- A count of cheque books, not an amount. Created as DECIMAL by the same name-based type guess
that got the two charges above wrong, and stored "3.000000" for three books. -->
<changeSet id="0470_free_cheque_books_is_a_count" author="CBS" runOnChange="false">
<modifyDataType tableName="m_product_loan_cbs_ext" columnName="free_cheque_books_per_year"
newDataType="INTEGER"/>
</changeSet>
</databaseChangeLog>
@@ -76,6 +76,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// for account close
private final Collection<EnumOptionData> onAccountClosureOptions;
private final Collection<PaymentTypeData> paymentTypeOptions;
private final Collection<EnumOptionData> maturityInstructionOptions;
// import fields
private transient Integer rowIndex;
@@ -364,6 +365,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
this.savingsAccounts = null;
this.onAccountClosureOptions = null;
this.paymentTypeOptions = null;
this.maturityInstructionOptions = null;
this.rowIndex = rowIndex;
this.dateFormat = dateFormat;
this.locale = locale;
@@ -394,6 +396,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<SavingsAccountData> savingsAccountDatas = null;
final Integer recurringFrequency = null;
final EnumOptionData recurringFrequencyType = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(depositAccountData.id, depositAccountData.accountNo, depositAccountData.externalId,
depositAccountData.groupId, depositAccountData.groupName, depositAccountData.clientId, depositAccountData.clientName,
@@ -416,7 +419,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate,
totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
isCalendarInherited, recurringFrequency, recurringFrequencyType, depositAccountData.withHoldTax,
depositAccountData.taxGroup);
depositAccountData.taxGroup, maturityInstructionOptions);
}
public static RecurringDepositAccountData withInterestChartAndRecurringDetails(final RecurringDepositAccountData account,
@@ -441,7 +444,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, recurringFrequency, recurringFrequencyType,
account.withHoldTax, account.taxGroup);
account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
return result;
}
@@ -463,11 +466,13 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions = null;
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<SavingsAccountData> savingsAccountDatas = account.savingsAccounts;
final Collection<EnumOptionData> maturityInstructionOptions = account.maturityInstructionOptions;
return withTemplateOptions(account, productOptions, fieldOfficerOptions, interestCompoundingPeriodTypeOptions,
interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions,
lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions,
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions);
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
final RecurringDepositAccountData result = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
@@ -486,10 +491,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
template.periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, template.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
// Merge accountTypeOptions and customerSegmentOptions from template
if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions());
@@ -507,7 +512,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> lockinPeriodFrequencyTypeOptions, final Collection<EnumOptionData> withdrawalFeeTypeOptions,
final Collection<SavingsAccountTransactionData> transactions, final Collection<SavingsAccountChargeData> charges,
final Collection<ChargeData> chargeOptions, final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions,
final Collection<EnumOptionData> periodFrequencyTypeOptions) {
final Collection<EnumOptionData> periodFrequencyTypeOptions, final Collection<SavingsAccountData> savingsAccountDatas,
final Collection<EnumOptionData> maturityInstructionOptions) {
final RecurringDepositAccountData r2 = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
account.clientId, account.clientName, account.depositProductId, account.depositProductName, account.fieldOfficerId,
@@ -524,10 +530,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, maturityInstructionOptions);
r2.copyCbsFieldsFrom(account);
return r2;
}
@@ -609,6 +615,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName,
fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -622,7 +629,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData preClosureDetails(final Long accountId, final BigDecimal maturityAmount,
@@ -700,6 +708,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(accountId, accountNo, externalId, groupId, groupName, clientId, clientName, productId,
productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -713,7 +722,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData withClosureTemplateDetails(final RecurringDepositAccountData account,
@@ -739,7 +749,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
closureResult.copyCbsFieldsFrom(account);
return closureResult;
}
@@ -774,7 +784,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final BigDecimal totalOverdueAmount, final Integer noOfOverdueInstallments, final boolean isMandatoryDeposit,
final boolean allowWithdrawal, final boolean adjustAdvanceTowardsFuturePayments, final boolean isCalendarInherited,
final Integer recurringFrequency, final EnumOptionData recurringFrequencyType, final boolean withHoldTax,
final TaxGroupData taxGroup) {
final TaxGroupData taxGroup, final Collection<EnumOptionData> maturityInstructionOptions) {
super(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldofficerId, fieldofficerName,
status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, interestPostingPeriodType,
@@ -818,6 +828,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// account close template options
this.onAccountClosureOptions = onAccountClosureOptions;
this.paymentTypeOptions = paymentTypeOptions;
this.maturityInstructionOptions = maturityInstructionOptions;
}
@Override
@@ -3008,7 +3008,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance);
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
}
public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) {
@@ -3700,10 +3703,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
private Money minRequiredBalanceDerived(final MonetaryCurrency currency) {
// Minimum required balance is not enforced -- withdrawals (and every other balance check
// that goes through this) must never be blocked by a configured minimum balance. Overdraft
// headroom is a separate concept and still applies.
Money minReqBalance = Money.zero(currency);
if (this.enforceMinRequiredBalance) {
minReqBalance = minReqBalance.plus(this.minRequiredBalance);
}
if (this.allowOverdraft) {
minReqBalance = minReqBalance.minus(this.overdraftLimit);
}
@@ -82,6 +82,11 @@ public class SavingsAccountRepositoryWrapper {
return account;
}
@Transactional(readOnly = true)
public boolean existsWithDepositAccountType(final Long savingsId, final DepositAccountType depositAccountType) {
return this.repository.findByIdAndDepositAccountType(savingsId, depositAccountType.getValue()) != null;
}
@Transactional(readOnly = true)
public List<SavingsAccount> findSavingAccountByClientId(@Param("clientId") Long clientId) {
List<SavingsAccount> accounts = this.repository.findSavingAccountByClientId(clientId);
@@ -160,7 +160,11 @@ public class FacilityChargeAutoApplyService {
}
}
if (!transactions.isEmpty()) {
savingsAccountTransactionRepository.saveAll(transactions);
// Flush immediately (not just save) so each transaction gets its DB-generated id right
// away -- the caller posts GL journal entries for these transactions straight after this
// call returns, and that requires a real id on each one, not one that only appears at
// end-of-transaction commit.
savingsAccountTransactionRepository.saveAllAndFlush(transactions);
account.recalculateBalancesAfterFacilityCharges(transactionDate);
}
return transactions;
@@ -79,6 +79,9 @@ public interface SavingsAccountWritePlatformService {
void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds);
void postJournalEntriesForSavingsAccount(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds, boolean backdatedTxnsAllowedTill);
CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command);
void setSubStatusInactive(Long savingsId);
@@ -172,7 +172,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
try {
HashMap h = fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper()));
@@ -198,7 +198,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
try {
HashMap h = fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper()));
@@ -224,7 +224,7 @@ public class FixedDepositTest extends IntegrationTest {
moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class);
moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP));
fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl(
new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper());
new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper());
BigDecimal expectedResult = new BigDecimal("10506.250000");
BigDecimal actualResult = new BigDecimal(fixedDepositAccountInterestCalculationServiceImpl
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper())).get("maturityAmount").toString());