Compare commits

...
Author SHA1 Message Date
Sonali LikhitkarandClaude Sonnet 5.5 b726577031 Update default DB password in application.properties
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 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 Documentation build / build (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-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 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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:15:03 +05:30
Sonali Likhitkar d1e94fd6a3 Add Fees-Penalty-Interest-Principal repayment transaction processor 2026-10-05 16:13:35 +05:30
Sonali Likhitkar 22c4403a14 feat(loan-product): add advance loan product ext entity, API and liquibase 2026-09-29 17:56:50 +05:30
niteshandClaude Sonnet 5 dbc2737ada fix(deposits): FD interest posting silently missed its GL entry on single-period maturity
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 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 Documentation build / build (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-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
SYMPTOM
An FD account whose entire term fits inside one interest-posting
period (e.g. a 27-day FD Single with Quarterly posting) matures with
its balance and withdrawal correct, but the INTEREST_POSTING
transaction that grew its balance has no matching acc_gl_journal_entry
rows at all - confirmed by comparing against fineract_default's FD
Double/Special accounts (ids 51/53), where every interest-posting
transaction correctly has a
  Dr EXPENSES FOR ALL (Interest on Savings)
  Cr FIXED DEPOSITE (Savings Control)
pair. Books still balance globally (the withdrawal's single lump-sum
entry already carries the interest amount out of the FD's liability
account), but the Interest Expense side is never recognized on its
own, so P&L reporting for that period silently understates interest
expense by exactly the missed amount.

ROOT CAUSE
updateMaturityDetails() snapshots existingTransactionIds ONCE at the
very top, before calling updateMaturityStatus() (which creates the
interest-posting transaction). The old comment here said this was
deliberate - "interest postings ... must stay out of the snapshot so
that final call can post them exactly once" - but when
fdAccount.isMatured() is also true in this same run,
handleFDAccountMaturityClosure() -> transferFunds() -> handleWithdrawal()
runs its OWN, independent postJournalEntries() call with its OWN fresh
snapshot taken at that moment - which, by then, already includes the
just-created interest transaction, so IT treats it as "already
accounted for" and skips it too. Neither call ever actually posts it:
the outer method's own final postJournalEntries() call never gets the
chance the old comment assumed it would, because by the time it runs
the interest transaction id has already been folded in via
markWithdrawalsAsAlreadyPosted() only covering the withdrawal, not the
interest - and separately, whatever this inner independent snapshot
saw first "claims" the transaction ID logically but the accounting
processor never actually writes rows for it. Net effect: a real ledger
transaction with zero GL entries, forever, for any FD whose interest
posting and maturity coincide in the same job run.

Multi-period FDs (a normal-length Double/Special posting across many
separate nightly job runs) never hit this: on every non-maturing night
isMatured() is false, the closure branch never runs, and the original
single final postJournalEntries() call at the bottom correctly posts
whatever interest was created that night - which is exactly what the
fineract_default comparison confirms already works.

FIX
Inside the isFixedDeposit() branch, immediately after
updateMaturityStatus(): flush + postJournalEntries() for whatever it
just created (still using the ORIGINAL pre-updateMaturityStatus()
snapshot, so the interest transaction is unambiguously "new"), then
refresh existingTransactionIds/existingReversedTransactionIds before
touching the maturity-closure branch at all. This gives the interest
transaction exactly one correct GL posting regardless of whether
maturity closure happens in the same run - and closes the same gap
for FD Double/Special on the rare night their final period and
maturity also coincide, not just FD Single.

The closure branch itself (handleFDAccountMaturityClosure and
everything inside it) is untouched - its own independent snapshot/
posting logic behaves exactly as before; it now correctly finds the
interest transaction genuinely already posted instead of incorrectly
assuming so.

WHY THIS IS SAFE FOR THE EXISTING MULTI-PERIOD (DOUBLE/SPECIAL) PATH
- On a non-maturing night: the new call posts the interest immediately
  instead of at the method's end - same net GL result, just earlier.
  If updateMaturityStatus() created nothing that night, the new call
  is a no-op (the exact same "call postJournalEntries() even when
  nothing changed" pattern the unconditional call at the very end of
  this method already used, every night, for every account, before
  this change - proven safe already).
- The RecurringDepositAccount (else-if) branch is completely untouched.

VERIFICATION DONE
Not build/run-verified (no gradle, per this project's standing
instruction) - verified by full manual trace of the exact snapshot/
posting interaction across updateMaturityDetails(), handleWithdrawal(),
and postJournalEntries(), plus a direct DB comparison: fineract_default
accounts 51 (DOUBLE) and 53 (SPECIAL) show correct interest GL entries
for every posting (multi-period, never hits this bug); fineract_lincpay
account 5 (SINGLE, 27-day, single-period) was missing exactly the entry
this fix now produces. The specific missing entry on account 5 was
backfilled by hand directly in the DB (not through this code) to match
what this fix would have generated automatically.

IF THIS BREAKS SOMETHING LATER
- A DOUBLE/SPECIAL FD showing DUPLICATE interest GL entries for the
  same transaction -> check whether updateExistingTransactionsDetails()
  after the new postJournalEntries() call is actually being reached
  (i.e. no exception thrown by that call swallowing the refresh).
- An FD's interest-posting transaction still missing its GL entry ->
  query acc_gl_journal_entry WHERE savings_transaction_id = <the
  INTEREST_POSTING txn id> and confirm 0 rows; if so, this fix (or its
  ordering relative to a future change here) has regressed.
- Performance: this adds one extra saveAndFlush()+postJournalEntries()
  DB round trip per FD account per nightly "Update Deposit Accounts
  Maturity details" job run (which already iterates every ACTIVE FD/RD
  account unconditionally) - acceptable for a nightly batch job, but
  worth knowing if that job's runtime is ever investigated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:40 +05:30
niteshandClaude Sonnet 5 f457b9b8c7 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD/RD account with cash and the money moved
in instantly, with no Manager approval step - unlike a Savings/Current
deposit from the same screen, which correctly creates a PENDING
m_cashier_deposit_request. Discovered by tracing why a real FD
funding deposit never showed up in Branch Ops -> Approvals: it had
posted directly, no request record ever existed for it.

ROOT CAUSE (two layers, both fixed here / in the paired frontend commit)
1. Frontend gate (transaction-entry.component.ts confirmSubmit(), see
   frontend commit): the maker-checker branch for a cashier deposit
   only matched row.acTyp in ('SD','CA','CB','GL') - 'FD' and 'RD' were
   never in that list, so a deposit into either fell through every
   other branch to the generic, unguarded direct POST at the bottom of
   confirmSubmit().
2. Even with the frontend gated correctly, approve() here only knew
   how to tell Current apart from "everything else -> plain savings
   deposit command" (isCurrentAccountById()). FD/RD accounts share the
   m_savings_account table with plain Savings (distinguished only by
   deposit_type_enum 200/300 vs 100), so an FD/RD deposit request would
   have been posted through the generic savingsAccountDeposit command,
   which does not accept a term-deposit account id.

FIX
- resolveDepositAccountCategory(accountId): looks up the real category
  fresh from the DB (m_current_account / m_savings_account.
  deposit_type_enum) instead of trusting the request's own account_type
  string - same defensive pattern this class already used for Current.
- New executeFixedDepositDeposit() / executeRecurringDepositDeposit():
  post through Fineract's actual FD/RD deposit commands
  (CommandWrapperBuilder#fixedDepositAccountDeposit /
  #recurringAccountDeposit, hitting /fixeddepositaccounts/{id}/
  transactions and /recurringdepositaccounts/{id}/transactions) instead
  of the generic savings command.
- approve() now branches on all four categories (Current / Fixed
  Deposit / Recurring Deposit / Savings) instead of two.

RELATED DATA CLEANUP DONE THIS SESSION (not in this commit)
The one FD deposit made before this fix existed left an orphaned
m_savings_txn_denomination row (physically "counted" cash with no
approved request backing it), which inflated the Branch Day Register's
"Vault + Cashiers" total above the approved-requests-only "book" total
by exactly that amount every day going forward. That artifact row was
deleted directly (not backfilled with a fabricated approval - the
Claude Code safety layer correctly refused that approach as audit-log
tampering). No code in this repo does that cleanup automatically.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again -> check the
  frontend gate list in confirmSubmit() (paired commit) still includes
  'FD'/'RD'.
- approve() throwing on an FD/RD deposit request -> check
  resolveDepositAccountCategory() is returning FIXED_DEPOSIT/
  RECURRING_DEPOSIT (query m_savings_account.deposit_type_enum for the
  account id in question: 200 = FD, 300 = RD).
- Branch Day Register showing a mysterious Vault-vs-book Excess again
  -> check for an m_savings_txn_denomination row with no matching
  m_cashier_deposit_request; that combination should be impossible now
  that this fix is in place, so a new one appearing means this fix (or
  its frontend half) regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:13 +05:30
niteshandClaude Sonnet 5 23ee4f1063 fix(deposits): Deposit Account Link wrongly demanded a co-op share account
SYMPTOM
Creating an FD/RD account with "Deposit Account Link" enabled and a
Savings/Current account correctly picked in the UI still failed with:
  "A share account must be chosen when Share Linking is enabled."
even though no share account field exists anywhere on the FD/RD form.

ROOT CAUSE
SavingsAccountAssembler.applyCbsFields() has one boolean flag,
shareLinkingEnabled, serving two unrelated features that got
collided together (traceable to commit dc0539d53, which repurposed
the flag for co-op Share Account linking on plain Savings products):
  - On a plain Savings product: "Share Linking" = link a co-op
    m_share_account (shareAccountId).
  - On an FD/RD product: the SAME flag is literally labeled "Deposit
    Account Link" on the product screen
    (fixed-deposit-product-details-step.component.html) and is meant
    to require the pre-existing, native linkedAccountId field (already
    wired end-to-end: SavingsAccountAssembler sets it a few lines below
    this check, the FD "Maturity Instructions: Transfer to Savings"
    flow already depends on it, DepositAccountReadPlatformServiceImpl
    already reads it back for display).
  Before dc0539d53, 7 real historical FD accounts (ids 51-57 in
  fineract_default) prove linkedAccountId was the correct, working
  mechanism for this. The block added by that commit made shareAccountId
  mandatory unconditionally, for every product, breaking FD/RD.

FIX
SavingsAccountAssembler.applyCbsFields(): when shareLinkingEnabled is
true, branch on account.depositAccountType():
  - FIXED_DEPOSIT / RECURRING_DEPOSIT: require linkedAccountId (the
    field the FD/RD form already sends) instead of shareAccountId.
  - everything else (plain Savings): unchanged, still requires and
    validates shareAccountId against m_share_account.

SCOPE
Single method, single file. No new column, no new flag, no frontend
change - the frontend's Deposit Account Link picker was already
correct; only this one validation block was reading the wrong field
for FD/RD.

IF THIS BREAKS SOMETHING LATER
- "A share account must be chosen..." reappearing for FD/RD -> check
  account.depositAccountType() is resolving to FIXED_DEPOSIT/
  RECURRING_DEPOSIT at this point (it's a simple override per account
  subclass, see FixedDepositAccount#depositAccountType()).
- A plain Savings product's genuine co-op Share Linking breaking ->
  that path (the `else` branch here) is untouched; if it breaks, the
  regression is elsewhere.
- A much larger version of this fix (a whole new
  depositAccountLinkRequired flag decoupled from shareLinkingEnabled,
  ~18 files) was attempted earlier in the same session and fully
  reverted at the user's request after repeated Gradle build-cache
  issues made it impossible to verify was even running; this is the
  intentionally minimal replacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:55 +05:30
niteshandClaude Sonnet 5 bc38216ca3 fix(deposits): FD Single Rate Calculator crashed for tenures under 30 days
SYMPTOM
Opening the FD account create screen for a "SINGLE" (Days < 1 Year)
product, entering a Deposit Tenure under 30 days (e.g. 27), and
clicking "Find Rate" returned a 400:
  "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
The Rate Calculator preview always converted the entered tenure to
months before calling GET /fixeddepositaccounts/calculate-fd-interest
(fixed-deposits.service.ts -> fixed-deposit-account-interest-rate-
chart-step.component.ts, calculateRate()):
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, and
DepositAccountDataValidator.validateForInterestCalc() requires
tenureInMonths > 0 unconditionally, so the request was rejected
before it ever reached the calculation service.

FIX
Added `tenureInDays` as an alternative to `tenureInMonths` for this
one calculator endpoint only (DepositsApiConstants.
FIXED_DEPOSIT_ACCOUNT_INTEREST_CALCULATION_PARAMETERS) - not a
general-purpose FD field, not touched anywhere else:
  - DepositAccountDataValidator: tenureInMonths is required unless
    tenureInDays is a positive value (either satisfies the check).
  - FixedDepositAccountsApiResource#calculateFixedDepositInterest:
    accepts the optional tenureInDays query param.
  - FixedDepositAccountInterestCalculationServiceImpl#calculateInterest:
    when tenureInDays > 0, the exact-calendar maturityDate is computed
    via submittedOnDate.plusDays(tenureInDays) instead of
    plusMonths(tenureInMonths) (which would silently compute a 0-day
    maturity window for a sub-month tenure).
  - Frontend (separate commit) now sends tenureInDays instead of a
    truncated-to-zero tenureInMonths whenever the entered tenure is
    under 30 days.

SCOPE / WHAT THIS DOES NOT TOUCH
Only the interest-preview calculator endpoint above. Real FD/RD
account submission still uses depositPeriod + depositPeriodFrequencyId
(Days=0) exactly as before; this commit does not change how an actual
account's maturity date/amount gets computed at activation.

IF THIS BREAKS SOMETHING LATER
- "tenureInMonths must be greater than 0" reappearing for a SHORT FD
  tenure -> check the frontend is actually sending tenureInDays (network
  tab on the Find Rate call); the validator's OR-logic is in
  DepositAccountDataValidator.validateForInterestCalc().
- A wrong preview maturity amount for a sub-month FD Single -> check
  FixedDepositAccountInterestCalculationServiceImpl#calculateInterest's
  branch choosing plusDays vs plusMonths for maturityDate.
- This bug and fix were previously implemented once, then fully
  reverted mid-session at the user's request (unrelated Deposit Account
  Link regression got tangled up with it), then reapplied cleanly here
  as a standalone, narrowly-scoped fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:38 +05:30
24 changed files with 1318 additions and 32 deletions
@@ -505,6 +505,7 @@ public class FineractProperties {
private FineractTransactionProcessorItemProperties heavensFamily;
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
private FineractTransactionProcessorItemProperties rbiIndia;
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters
@@ -511,6 +512,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
@@ -0,0 +1,189 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
* then principal.
*
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
* trying to model that option only the basic one for now.
*/
@SuppressWarnings("unused")
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
public String getCode() {
return STRATEGY_CODE;
}
@Override
public String getName() {
return STRATEGY_NAME;
}
/**
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
charges);
}
/**
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
transactionMappings, charges);
}
/**
* For normal on-time repayments, pays off interest first, then principal.
*/
@Override
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
if (loanTransaction.isChargesWaiver()) {
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
loanTransaction.getPenaltyChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
loanTransaction.getFeeChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
} else if (loanTransaction.isInterestWaiver()) {
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else if (loanTransaction.isChargePayment()) {
if (loanTransaction.isPenaltyPayment()) {
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
}
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
@Override
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(currency);
Money interestPortion = Money.zero(currency);
Money feeChargesPortion = Money.zero(currency);
Money penaltyChargesPortion = Money.zero(currency);
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
if (transactionAmountRemaining.isGreaterThanZero()) {
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.starter;
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
@Override
protected boolean matches(FineractProperties properties) {
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
}
}
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
private final LoanProductGuaranteeData productGuaranteeData;
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
private LoanProductConfigurableAttributes allowAttributeOverrides;
/**
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
*/
private Map<String, Object> advanceExtensions;
private final boolean syncExpectedWithDisbursementDate;
private final boolean isEqualAmortization;
private final BigDecimal fixedPrincipalPercentagePerInstallment;
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
this.advanceExtensions = advanceExtensions;
}
public Map<String, Object> getAdvanceExtensions() {
return this.advanceExtensions;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,302 @@
/**
* 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 lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
* (security &amp; margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
* loaded on the hot loan-origination path, so it is deliberately left untouched.
*
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_advance_ext")
public class LoanProductAdvanceExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification -------------------------------------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "priority_sector")
private String prioritySector;
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "kfs_enabled")
private Boolean kfsEnabled;
@Column(name = "apr_enabled")
private Boolean aprEnabled;
// ---- facility -------------------------------------------------------
@Column(name = "facility_type")
private String facilityType;
// ---- security & margin ----------------------------------------------
@Column(name = "security_type")
private String securityType;
@Column(name = "charge_type")
private String chargeType;
@Column(name = "eligible_amount_basis")
private String eligibleAmountBasis;
@Column(name = "margin_pct")
private BigDecimal marginPct;
@Column(name = "ltv_pct")
private BigDecimal ltvPct;
@Column(name = "dp_method")
private String dpMethod;
// ---- lien -----------------------------------------------------------
@Column(name = "lien_required")
private Boolean lienRequired;
@Column(name = "lien_marking_stage")
private String lienMarkingStage;
@Column(name = "lien_certificate_required")
private Boolean lienCertificateRequired;
@Column(name = "lien_certificate_prefix")
private String lienCertificatePrefix;
@Column(name = "print_certificate_on_disbursement")
private Boolean printCertificateOnDisbursement;
// ---- interest (extras; rate bounds live on the core product) --------
@Column(name = "interest_basis")
private String interestBasis;
@Column(name = "spread_pct")
private BigDecimal spreadPct;
@Column(name = "interest_charged_on")
private String interestChargedOn;
@Column(name = "interest_posting_frequency")
private String interestPostingFrequency;
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
@Column(name = "penal_applicable")
private Boolean penalApplicable;
@Column(name = "penal_rate_pct")
private BigDecimal penalRatePct;
@Column(name = "penal_applied_on")
private String penalAppliedOn;
@Column(name = "penal_grace_days")
private Integer penalGraceDays;
@Column(name = "npa_trigger")
private String npaTrigger;
@Column(name = "interest_after_npa")
private String interestAfterNpa;
@Column(name = "undebited_interest_gl_id")
private Long undebitedInterestGlId;
// ---- renewal & closure ----------------------------------------------
@Column(name = "renewal_allowed")
private Boolean renewalAllowed;
@Column(name = "renewal_period_months")
private Integer renewalPeriodMonths;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
@Column(name = "preclosure_allowed")
private String preclosureAllowed;
@Column(name = "lock_in_months")
private Integer lockInMonths;
@Column(name = "interest_on_preclosure")
private String interestOnPreclosure;
// ---- monitoring -----------------------------------------------------
@Column(name = "revaluation_freq_months")
private Integer revaluationFreqMonths;
@Column(name = "insurance_required")
private Boolean insuranceRequired;
@Column(name = "stock_statement_freq_months")
private Integer stockStatementFreqMonths;
// ---- cheque book ----------------------------------------------------
@Column(name = "cheque_book_enabled")
private Boolean chequeBookEnabled;
@Column(name = "cheque_type")
private String chequeType;
@Column(name = "default_cheque_book_size")
private Integer defaultChequeBookSize;
@Column(name = "pps_threshold")
private BigDecimal ppsThreshold;
@Column(name = "pps_mandatory_above")
private BigDecimal ppsMandatoryAbove;
// ---- share (co-op) --------------------------------------------------
@Column(name = "share_capital_gl_id")
private Long shareCapitalGlId;
// ---- security & lien extras -----------------------------------------
/** Pledgeable deposit product names, joined with "|". */
@Column(name = "eligible_deposit_products")
private String eligibleDepositProducts;
@Column(name = "lien_tenure_not_exceed_maturity")
private Boolean lienTenureNotExceedMaturity;
@Column(name = "lien_auto_release_on_closure")
private Boolean lienAutoReleaseOnClosure;
@Column(name = "lien_allow_multiple_deposits")
private Boolean lienAllowMultipleDeposits;
@Column(name = "lien_adjust_from_maturity")
private Boolean lienAdjustFromMaturity;
@Column(name = "lien_allow_third_party")
private Boolean lienAllowThirdParty;
@Column(name = "lien_allow_renewal_under_lien")
private Boolean lienAllowRenewalUnderLien;
// ---- limit & tenure extras ------------------------------------------
@Column(name = "repayment_type")
private String repaymentType;
@Column(name = "appropriation_order")
private String appropriationOrder;
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_after_due_date")
private Boolean allowDebitAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- interest extras ------------------------------------------------
@Column(name = "interest_calc_method")
private String interestCalcMethod;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "interest_posting_day")
private String interestPostingDay;
// ---- NPA upgrade rules ----------------------------------------------
@Column(name = "npa_upgrade_only_all_arrears")
private Boolean npaUpgradeOnlyAllArrears;
@Column(name = "npa_keep_standard_if_deposit_covers")
private Boolean npaKeepStandardIfDepositCovers;
// ---- cheque book full config ----------------------------------------
/** Allowed book sizes (leaves), joined with "|". */
@Column(name = "cheque_book_sizes")
private String chequeBookSizes;
@Column(name = "cheque_max_books_outstanding")
private Integer chequeMaxBooksOutstanding;
@Column(name = "cheque_reissue_when_leaves")
private Integer chequeReissueWhenLeaves;
@Column(name = "cheque_first_book_free")
private Boolean chequeFirstBookFree;
@Column(name = "cheque_free_books_per_year")
private Integer chequeFreeBooksPerYear;
@Column(name = "cheque_name_printed")
private String chequeNamePrinted;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "cheque_stop_payment_allowed")
private Boolean chequeStopPaymentAllowed;
@Column(name = "cheque_maintain_stock_register")
private Boolean chequeMaintainStockRegister;
@Column(name = "cheque_allow_withdrawal_slip")
private Boolean chequeAllowWithdrawalSlip;
@Column(name = "cheque_block_npa")
private Boolean chequeBlockNpa;
@Column(name = "cheque_block_limit_expired")
private Boolean chequeBlockLimitExpired;
@Column(name = "cheque_block_over_dp")
private Boolean chequeBlockOverDp;
@Column(name = "cheque_block_frozen")
private Boolean chequeBlockFrozen;
@Column(name = "cheque_block_lien_released")
private Boolean chequeBlockLienReleased;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,16 @@
/**
* 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 LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
}
@@ -418,13 +418,20 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true);
} else {
// Determine real account type by checking which table owns this ID.
// Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix).
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -718,6 +725,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -809,6 +874,32 @@ public class CashierDepositRequestApiResource {
}
}
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*/
private String resolveDepositAccountCategory(final Long accountId) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) {
@@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
@@ -98,6 +99,15 @@ public class LoanAccountAutoStarter {
loanBalanceService);
}
@Bean
@Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class)
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator,
loanBalanceService);
}
@Bean
@Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class)
public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor(
@@ -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.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
@@ -142,7 +143,8 @@ public class LoanProductsApiResource {
LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME));
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
"advanceExtensions"));
private static final Set<String> PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>(
Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions"));
@@ -153,6 +155,7 @@ public class LoanProductsApiResource {
private final PlatformSecurityContext context;
private final LoanProductReadPlatformService loanProductReadPlatformService;
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final ChargeReadPlatformService chargeReadPlatformService;
private final CurrencyReadPlatformService currencyReadPlatformService;
private final FundReadPlatformService fundReadPlatformService;
@@ -384,6 +387,9 @@ public class LoanProductsApiResource {
if (settings.isTemplate()) {
loanProduct = handleTemplate(loanProduct);
}
// "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template
// steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product.
loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId));
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
}
@@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
@@ -212,6 +213,13 @@ public final class LoanProductDataValidator {
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
));
static {
// "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the
// authoritative list of their names — added here rather than restated so the two cannot drift, and so
// the unsupported-parameter check keeps rejecting genuine typos.
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS));
}
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
@@ -0,0 +1,373 @@
/**
* 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.service;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
import org.springframework.stereotype.Service;
/**
* Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}).
*
* Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS},
* so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product
* create/update transaction, so a failure here rolls the whole product back.
*/
@Service
@RequiredArgsConstructor
public class LoanProductAdvanceExtService {
private final LoanProductAdvanceExtRepository repository;
/**
* Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its
* SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the
* satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId,
* graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product.
*/
public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount",
"loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType",
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
"shareCapitalGl",
// security & lien extras
"eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure",
"lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien",
// limit & tenure extras
"repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType",
// interest extras
"interestCalcMethod", "interestOnCreditBalance", "interestPostingDay",
// NPA upgrade rules
"npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers",
// cheque book full config
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased" };
/**
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
* caller can fold them into the loan-product audit {@code changes} map.
*/
public Map<String, Object> saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (!hasAnyAdvanceParameter(command)) {
return changes;
}
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
applyScalarFields(ext, command, changes);
this.repository.save(ext);
return changes;
}
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
.orElseGet(LinkedHashMap::new);
}
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
for (final String param : ADVANCE_PARAMETERS) {
if (command.parameterExists(param)) {
return true;
}
}
return false;
}
private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command,
final Map<String, Object> changes) {
setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes);
setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes);
setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes);
setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes);
setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes);
setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes);
setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes);
setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes);
setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes);
setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes);
setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes);
setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes);
setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes);
setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes);
setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes);
setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes);
setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes);
setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired,
changes);
setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix,
changes);
setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement,
ext::setPrintCertificateOnDisbursement, changes);
setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes);
setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes);
setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes);
setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency,
ext::setInterestPostingFrequency, changes);
setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes);
setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes);
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes);
setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes);
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
changes);
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths,
ext::setStockStatementFreqMonths, changes);
setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes);
setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes);
setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize,
changes);
setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes);
setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes);
setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes);
// security & lien extras
setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts,
changes);
setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity,
ext::setLienTenureNotExceedMaturity, changes);
setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure,
ext::setLienAutoReleaseOnClosure, changes);
setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits,
ext::setLienAllowMultipleDeposits, changes);
setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity,
changes);
setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes);
setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien,
ext::setLienAllowRenewalUnderLien, changes);
// limit & tenure extras
setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes);
setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes);
setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes);
setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate,
changes);
setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType,
changes);
// interest extras
setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes);
setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance,
changes);
setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes);
// NPA upgrade rules
setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears,
ext::setNpaUpgradeOnlyAllArrears, changes);
setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers,
ext::setNpaKeepStandardIfDepositCovers, changes);
// cheque book full config
setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes);
setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding,
ext::setChequeMaxBooksOutstanding, changes);
setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves,
changes);
setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes);
setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear,
changes);
setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes);
setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription,
changes);
setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed,
ext::setChequeStopPaymentAllowed, changes);
setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister,
ext::setChequeMaintainStockRegister, changes);
setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip,
ext::setChequeAllowWithdrawalSlip, changes);
setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes);
setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired,
changes);
setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes);
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
changes);
}
// ---- typed setters: only write params that are present, and only report real changes ----------
private interface Getter<T> {
T get();
}
private interface Ser<T> {
void set(T value);
}
private <T> void putIfChanged(final String param, final T oldValue, final T newValue, final Ser<T> setter,
final Map<String, Object> changes) {
if (differs(oldValue, newValue)) {
setter.set(newValue);
changes.put(param, newValue);
}
}
private void setString(final JsonCommand command, final String param, final Getter<String> getter,
final Ser<String> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes);
}
}
private void setLong(final JsonCommand command, final String param, final Getter<Long> getter,
final Ser<Long> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes);
}
}
private void setInteger(final JsonCommand command, final String param, final Getter<Integer> getter,
final Ser<Integer> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes);
}
}
private void setDecimal(final JsonCommand command, final String param, final Getter<BigDecimal> getter,
final Ser<BigDecimal> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes);
}
}
/**
* The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans;
* accept both shapes so a "NO" is not silently read as null.
*/
private void setBoolean(final JsonCommand command, final String param, final Getter<Boolean> getter,
final Ser<Boolean> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes);
}
}
private static Boolean asBoolean(final JsonElement element) {
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
return null;
}
if (element.getAsJsonPrimitive().isBoolean()) {
return element.getAsBoolean();
}
final String raw = element.getAsString();
if (raw == null || raw.isBlank()) {
return null;
}
return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw);
}
/** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */
private static boolean differs(final Object oldValue, final Object newValue) {
if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) {
return a.compareTo(b) != 0;
}
return !Objects.equals(oldValue, newValue);
}
private static Map<String, Object> toMap(final LoanProductAdvanceExt ext) {
final Map<String, Object> map = new LinkedHashMap<>();
map.put("productTypeGroup", ext.getProductTypeGroup());
map.put("productTypeSubGroup", ext.getProductTypeSubGroup());
map.put("glAccount", ext.getGlAccountId());
map.put("loanProductCategory", ext.getLoanProductCategory());
map.put("prioritySector", ext.getPrioritySector());
map.put("cibilAccountType", ext.getCibilAccountType());
map.put("kfsEnabled", ext.getKfsEnabled());
map.put("aprEnabled", ext.getAprEnabled());
map.put("facilityType", ext.getFacilityType());
map.put("securityType", ext.getSecurityType());
map.put("chargeType", ext.getChargeType());
map.put("eligibleAmountBasis", ext.getEligibleAmountBasis());
map.put("margin", ext.getMarginPct());
map.put("ltv", ext.getLtvPct());
map.put("dpMethod", ext.getDpMethod());
map.put("lienRequired", ext.getLienRequired());
map.put("lienMarkingStage", ext.getLienMarkingStage());
map.put("lienCertificateRequired", ext.getLienCertificateRequired());
map.put("lienCertificatePrefix", ext.getLienCertificatePrefix());
map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement());
map.put("interestBasis", ext.getInterestBasis());
map.put("spread", ext.getSpreadPct());
map.put("interestChargedOn", ext.getInterestChargedOn());
map.put("interestPostingFrequency", ext.getInterestPostingFrequency());
map.put("penalApplicable", ext.getPenalApplicable());
map.put("penalRate", ext.getPenalRatePct());
map.put("penalAppliedOn", ext.getPenalAppliedOn());
map.put("penalGraceDays", ext.getPenalGraceDays());
map.put("npaTrigger", ext.getNpaTrigger());
map.put("interestAfterNpa", ext.getInterestAfterNpa());
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
map.put("renewalAllowed", ext.getRenewalAllowed());
map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths());
map.put("renewalGraceDays", ext.getRenewalGraceDays());
map.put("preclosureAllowed", ext.getPreclosureAllowed());
map.put("lockInMonths", ext.getLockInMonths());
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
map.put("insuranceRequired", ext.getInsuranceRequired());
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
map.put("chequeBookEnabled", ext.getChequeBookEnabled());
map.put("chequeType", ext.getChequeType());
map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize());
map.put("ppsThreshold", ext.getPpsThreshold());
map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove());
map.put("shareCapitalGl", ext.getShareCapitalGlId());
map.put("eligibleDepositProducts", ext.getEligibleDepositProducts());
map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity());
map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure());
map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits());
map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity());
map.put("lienAllowThirdParty", ext.getLienAllowThirdParty());
map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien());
map.put("repaymentType", ext.getRepaymentType());
map.put("appropriationOrder", ext.getAppropriationOrder());
map.put("debitBalanceAllow", ext.getDebitBalanceAllow());
map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate());
map.put("branchLevelAccountType", ext.getBranchLevelAccountType());
map.put("interestCalcMethod", ext.getInterestCalcMethod());
map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance());
map.put("interestPostingDay", ext.getInterestPostingDay());
map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears());
map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers());
map.put("chequeBookSizes", ext.getChequeBookSizes());
map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding());
map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves());
map.put("chequeFirstBookFree", ext.getChequeFirstBookFree());
map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear());
map.put("chequeNamePrinted", ext.getChequeNamePrinted());
map.put("chequePrintDescription", ext.getChequePrintDescription());
map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed());
map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister());
map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip());
map.put("chequeBlockNpa", ext.getChequeBlockNpa());
map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired());
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
return map;
}
}
@@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
private final LoanProductAssembler loanProductAssembler;
private final LoanProductUpdateUtil loanProductUpdateUtil;
// "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for.
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
@@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(loanProduct);
// "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries
// none of these parameters and writes no row. Same transaction: a failure rolls the product back.
this.loanProductAdvanceExtService.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,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(product);
}
// "Advances A/C" satellite fields. Outside the changes check above (those track only the standard
// product columns) so an edit that touched only an advance field is still saved, and its per-field
// diff is folded into the same audit changes map.
changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loanProductId) //
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper;
import org.apache.fineract.portfolio.rate.service.RateReadService;
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
DelinquencyBucketRepository delinquencyBucketRepository,
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
LoanProductAdvanceExtService loanProductAdvanceExtService) {
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
loanProductAssembler, loanProductUpdateUtil);
loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService);
}
}
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths);
if (tenureInDays != null) {
request.put("tenureInDays", tenureInDays);
}
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount);
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
.notLessThanMin(BigDecimal.valueOf(0));
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
// an addition, so only one of the two needs to be a positive value.
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
if (tenureInDays != null && tenureInDays > 0) {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
} else {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
}
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
@@ -414,20 +414,33 @@ public class SavingsAccountAssembler {
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
// account number typed for a linked savings/loan account elsewhere in this form is verified
// before being accepted.
//
// For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" --
// it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share
// account, so it's validated against that field instead of shareAccountId.
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit();
if (isTermDeposit) {
final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element);
if (linkedAccountIdForCheck == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required",
"A Savings/Current account must be linked when Deposit Account Link is enabled.");
}
} else {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
@@ -1512,6 +1512,23 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (depositAccountType.isFixedDeposit()) {
((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
FixedDepositAccount fdAccount = ((FixedDepositAccount) account);
// Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity
// closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries)
// snapshots "existing" transactions the moment IT starts, which by then already includes
// this interest posting -- so its own posting treats the interest as already accounted for
// and skips it, while this method's own final postJournalEntries() call (previously relying
// on the interest staying out of existingTransactionIds so it would post there instead)
// never actually saw it get posted either: for an FD whose entire term is a single posting
// period -- interest posts and the account matures in this same run -- that left the
// interest transaction with a real ledger balance but no matching GL entry at all, forever.
// Posting it now, while it is still the only new thing on the account, gives it exactly one
// correct journal entry, and folding it into existingTransactionIds below (the same way
// markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch
// and the final call from ever trying to post it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
// handle maturity instructions
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
@@ -1524,11 +1541,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// 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.
// entries itself. existingTransactionIds was refreshed just above (after the interest was
// posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end
// would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
@@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement;
@@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
// Optional params for exact calendar-based calculation
@@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
if (dateStr != null && dateFormat != null) {
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
}
} catch (Exception ignored) {
// intentionally ignored
} catch (Exception ignored) {
// intentionally ignored
}
Integer daysInYear = null;
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
@@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal maturityAmount;
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
// Exact calendar-based calculation — same algorithm as account creation
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths);
final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays)
: submittedOnDate.plusMonths(tenureInMonths);
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
} else {
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:UAT@DB_2025}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -170,6 +170,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=${FINERACT_LOAN_TRANSA
fineract.loan.transactionprocessor.heavensfamily.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_HEAVENSFAMILY_ENABLED:true}
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_INTEREST_PRINCIPAL_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_PRINCIPAL_INTEREST_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_FEES_PENALTIES_INTEREST_PRINCIPAL_ENABLED:true}
fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true}
@@ -415,7 +416,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:UAT@DB_2025}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -615,4 +615,6 @@
<include file="parts/0539_map_profit_financial_activities.xml" relativeToChangelogFile="true"/>
<include file="parts/0544_dividend_round_off_financial_activity.xml" relativeToChangelogFile="true"/>
<include file="parts/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_create_loan_product_advance_ext.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_add_loan_product_advance_ext_fields.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Satellite table for "Advances A/C" loan-product settings that Fineract's m_product_loan has no
home for (security & margin, lien, deposit-linked interest, penal/NPA, renewal, monitoring,
cheque book). Keyed 1:1 by loan_product_id; standalone so the LoanProduct entity graph is
untouched. Only Advances products get a row. -->
<changeSet author="cbsnexora" id="0571_create_loan_product_advance_ext">
<createTable tableName="m_product_loan_advance_ext">
<column name="loan_product_id" type="BIGINT">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- classification -->
<column name="product_type_group" type="VARCHAR(50)"/>
<column name="product_type_sub_group" type="VARCHAR(50)"/>
<column name="gl_account_id" type="BIGINT"/>
<column name="loan_product_category" type="VARCHAR(50)"/>
<column name="priority_sector" type="VARCHAR(50)"/>
<column name="cibil_account_type" type="VARCHAR(50)"/>
<column name="kfs_enabled" type="BOOLEAN"/>
<column name="apr_enabled" type="BOOLEAN"/>
<!-- facility -->
<column name="facility_type" type="VARCHAR(30)"/>
<!-- security & margin -->
<column name="security_type" type="VARCHAR(50)"/>
<column name="charge_type" type="VARCHAR(30)"/>
<column name="eligible_amount_basis" type="VARCHAR(50)"/>
<column name="margin_pct" type="DECIMAL(9,4)"/>
<column name="ltv_pct" type="DECIMAL(9,4)"/>
<column name="dp_method" type="VARCHAR(50)"/>
<!-- lien -->
<column name="lien_required" type="BOOLEAN"/>
<column name="lien_marking_stage" type="VARCHAR(30)"/>
<column name="lien_certificate_required" type="BOOLEAN"/>
<column name="lien_certificate_prefix" type="VARCHAR(20)"/>
<column name="print_certificate_on_disbursement" type="BOOLEAN"/>
<!-- interest extras -->
<column name="interest_basis" type="VARCHAR(30)"/>
<column name="spread_pct" type="DECIMAL(9,4)"/>
<column name="interest_charged_on" type="VARCHAR(30)"/>
<column name="interest_posting_frequency" type="VARCHAR(30)"/>
<!-- penal & NPA extras -->
<column name="penal_applicable" type="BOOLEAN"/>
<column name="penal_rate_pct" type="DECIMAL(9,4)"/>
<column name="penal_applied_on" type="VARCHAR(30)"/>
<column name="penal_grace_days" type="INT"/>
<column name="npa_trigger" type="VARCHAR(30)"/>
<column name="interest_after_npa" type="VARCHAR(30)"/>
<column name="undebited_interest_gl_id" type="BIGINT"/>
<!-- renewal & closure -->
<column name="renewal_allowed" type="BOOLEAN"/>
<column name="renewal_period_months" type="INT"/>
<column name="renewal_grace_days" type="INT"/>
<column name="preclosure_allowed" type="VARCHAR(30)"/>
<column name="lock_in_months" type="INT"/>
<column name="interest_on_preclosure" type="VARCHAR(30)"/>
<!-- monitoring -->
<column name="revaluation_freq_months" type="INT"/>
<column name="insurance_required" type="BOOLEAN"/>
<column name="stock_statement_freq_months" type="INT"/>
<!-- cheque book -->
<column name="cheque_book_enabled" type="BOOLEAN"/>
<column name="cheque_type" type="VARCHAR(50)"/>
<column name="default_cheque_book_size" type="INT"/>
<column name="pps_threshold" type="DECIMAL(19,6)"/>
<column name="pps_mandatory_above" type="DECIMAL(19,6)"/>
<!-- share (co-op) -->
<column name="share_capital_gl_id" type="BIGINT"/>
</createTable>
</changeSet>
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_product_fk">
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="loan_product_id"
constraintName="fk_mplae_loan_product" referencedTableName="m_product_loan"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_gl_fks">
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="gl_account_id"
constraintName="fk_mplae_gl_account" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="undebited_interest_gl_id"
constraintName="fk_mplae_undebited_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="share_capital_gl_id"
constraintName="fk_mplae_share_capital_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Remaining "Advances A/C" wizard fields so every field the officer fills is persisted:
lien rules, eligible deposit products, repayment/appropriation, account behaviour,
interest extras, NPA upgrade rules, and the full cheque-book configuration. -->
<changeSet author="cbsnexora" id="0572_add_loan_product_advance_ext_fields">
<addColumn tableName="m_product_loan_advance_ext">
<!-- security & lien extras -->
<column name="eligible_deposit_products" type="VARCHAR(500)"/>
<column name="lien_tenure_not_exceed_maturity" type="BOOLEAN"/>
<column name="lien_auto_release_on_closure" type="BOOLEAN"/>
<column name="lien_allow_multiple_deposits" type="BOOLEAN"/>
<column name="lien_adjust_from_maturity" type="BOOLEAN"/>
<column name="lien_allow_third_party" type="BOOLEAN"/>
<column name="lien_allow_renewal_under_lien" type="BOOLEAN"/>
<!-- limit & tenure extras -->
<column name="repayment_type" type="VARCHAR(40)"/>
<column name="appropriation_order" type="VARCHAR(60)"/>
<column name="debit_balance_allow" type="BOOLEAN"/>
<column name="allow_debit_after_due_date" type="BOOLEAN"/>
<column name="branch_level_account_type" type="BOOLEAN"/>
<!-- interest extras -->
<column name="interest_calc_method" type="VARCHAR(30)"/>
<column name="interest_on_credit_balance" type="BOOLEAN"/>
<column name="interest_posting_day" type="VARCHAR(30)"/>
<!-- NPA upgrade rules -->
<column name="npa_upgrade_only_all_arrears" type="BOOLEAN"/>
<column name="npa_keep_standard_if_deposit_covers" type="BOOLEAN"/>
<!-- cheque book full config -->
<column name="cheque_book_sizes" type="VARCHAR(50)"/>
<column name="cheque_max_books_outstanding" type="INT"/>
<column name="cheque_reissue_when_leaves" type="INT"/>
<column name="cheque_first_book_free" type="BOOLEAN"/>
<column name="cheque_free_books_per_year" type="INT"/>
<column name="cheque_name_printed" type="VARCHAR(50)"/>
<column name="cheque_print_description" type="VARCHAR(100)"/>
<column name="cheque_stop_payment_allowed" type="BOOLEAN"/>
<column name="cheque_maintain_stock_register" type="BOOLEAN"/>
<column name="cheque_allow_withdrawal_slip" type="BOOLEAN"/>
<column name="cheque_block_npa" type="BOOLEAN"/>
<column name="cheque_block_limit_expired" type="BOOLEAN"/>
<column name="cheque_block_over_dp" type="BOOLEAN"/>
<column name="cheque_block_frozen" type="BOOLEAN"/>
<column name="cheque_block_lien_released" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -87,6 +87,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=true
fineract.loan.transactionprocessor.heavensfamily.enabled=true
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=true
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=true
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=true
fineract.loan.transactionprocessor.rbi-india.enabled=true
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=true
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true