Compare commits

...
Author SHA1 Message Date
keshav 1a2a945464 Merge branch 'origin/dev' into dev
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 Documentation build / build (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 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (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
2026-08-25 11:32:55 +05:30
keshav 73ec85cfd6 feat: implement account register, branch day, voucher verification, and share dividend backend APIs 2026-08-25 11:31:49 +05:30
niteshandClaude Sonnet 5 46f5a1babd feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-3) (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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (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 8 of 10) (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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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
WHAT THIS CHANGES
==================
Three related FD (Fixed Deposit) features, built incrementally in one session:

1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and
   Interest", the SAME m_savings_account row is now reused across every
   renewal (account number never changes) instead of closing the account
   and opening a new one. FixedDepositAccount.renewInPlace() does this by
   setting start_interest_calculation_date as a pivot: interest for terms
   before the pivot is never recalculated, only the new term's window is
   computed from there. A new one-to-many audit table m_fd_renewal_history
   records one row per term (renewal_no 0,1,2...) with that term's own
   principal, rate, dates, interest earned, maturity, and status. A
   companion table m_fd_renewal_chart_slab_history archives each closing
   term's full rate-by-tenure chart, captured just before renewInPlace
   overwrites it.

2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master
   (m_cbs_base_interest_rate_slab_history) was the only place that ever
   stored a senior-citizen premium. It is now also persisted on
   m_savings_account_interest_rate_slab (new senior_citizen_rate column,
   nullable, added only to this account-level table -- NOT to the shared
   InterestRateChartSlabFields embeddable, so the product-level table
   m_interest_rate_slab is untouched). Applied both at account creation
   (DepositAccountAssembler now prices a new FD off the active CBS base
   version when one exists for that FD type, falling back to the
   product's own chart otherwise) and at renewal (renewInPlace refreshes
   the chart from the same base version).

3. PAYMENT ACTION -- a new manual command, POST
   /fixeddepositaccounts/{id}?command=payment, available whether the FD
   is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close
   (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest
   Principal and Interest (via renewInPlace), Re-Invest Principal Only
   (legacy close-and-reopen, now also transfers the leftover interest to
   a chosen savings account instead of stranding it), and a new closure
   type PARTIAL_REINVEST (500) -- part of the matured balance renews in
   place on the same account, the remainder transfers to a savings
   account picked at request time. Withdraw Deposit is deliberately
   excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT
   plus its CHECKER twin were added in migration 0478.

KEY FILES
=========
- DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new),
  renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate
  matching with unit conversion across days/weeks and months/years
  families.
- FixedDepositAccount.java: renewInPlace, validateRenewalDate,
  isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking.
- SavingsAccount.java (SHARED with Savings and RD): added
  findLastTransactionBefore -- the interest-posting opening balance for a
  pivot-dated account now seeds from the last transaction strictly before
  the pivot, not the one on it. This fixes a bug where a renewed term
  could accrue zero interest, or double-subtract a same-day withdrawal,
  depending on transaction list order. Also widened
  calculateDateAccountIsLockedUntil to protected so renewInPlace can
  refresh lock-in on each renewed term.
- DepositAccountTransactionDataValidator.java: new validatePayment.
  validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can
  never reach the Close or Premature-Close handlers, which have no branch
  for it and would otherwise pay the whole balance out in cash. This
  applies to both FD and RD, since they share validateClosing.
- DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate
  (Close and Premature Close, excludes PARTIAL_REINVEST) split from the
  new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one
  private method with a flag. The renewal-history fallback query, for
  chains that predate m_fd_renewal_history, was rewritten to emit the
  exact same column shape as the primary query.
- New: FixedDepositRenewalHistory plus its repository,
  FixedDepositRenewalChartSlabHistory plus its repository,
  PaymentFixedDepositAccountCommandHandler.
- Migrations 0471 through 0478 under tenant/parts, each registered once
  in changelog-tenant.xml.

RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE
============================================================
RD and plain Savings share several of the touched files. Verified during
this work:
  - handleRDAccountClosure and RecurringDepositAccount.java were not
    modified.
  - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD
    and RD close and prematureClose commands, and excluded from their
    template option lists. This was a real regression caught mid-session:
    the enum value briefly leaked into RD's Close dropdown as a working
    looking option that actually paid the whole RD balance out in cash.
    It was found and fixed before this commit.
  - The SavingsAccount.calculateInterestUsing opening-balance change only
    affects accounts where start_interest_calculation_date differs from
    activatedon_date. On the dev DB this was exactly two accounts, both
    FD test accounts created this session. Before deploying, run:
      SELECT deposit_type_enum, COUNT(*) FROM m_savings_account
      WHERE start_interest_calculation_date IS NOT NULL
        AND start_interest_calculation_date <> activatedon_date
      GROUP BY deposit_type_enum;
    Any non-FD rows (100 or 300) there mean a Savings or RD account will
    get a different, corrected interest amount than before. This is
    expected to be rare or zero, but confirm before deploy.
  - DepositAccountAssembler's creation-time base-rate pricing is guarded
    by depositAccountType.isFixedDeposit, so RD account creation is
    unaffected.
  - is_active on InterestRateChart (migration 0474) is read only by
    FixedDepositProduct.activeChart, which only FD's renewInPlace and
    reInvest call. RD's applicableChart path is untouched. Migration 0474
    also retires (is_active = false) any chart whose end_date is already
    past. On the dev DB this retired zero rows; check on the target DB
    before deploy, since there is currently no API to reactivate a
    retired chart.

KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING
============================================================
  - Migrations 0471 through 0477, the DDL ones, have never actually
    executed via Liquibase. They were mirrored by hand directly into the
    dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so
    testing could proceed without a full rebuild each time. Liquibase
    will mark them MARK_RAN via preConditions on the dev DB's next real
    run, but their first genuine execution will be on whatever
    environment does not already have these columns and tables, such as
    a fresh QA or production database. Test a migration-from-scratch run
    before relying on this in production.
  - This code has not been compiled. Every new or changed method
    signature, import, and call site was manually cross-checked with
    grep against real declarations, but that is not a substitute for
    gradle actually compiling it. Run a full build before deploying and
    treat any compiler error as expected until confirmed clean.
  - DepositAccountAssembler pricing every new FD off the active CBS base
    rate master, instead of the product's own chart, whenever one exists
    is a real behaviour change affecting all new FD accounts of a type
    that has a base-rate version, not just senior-citizen ones. Flagging
    this for explicit product sign-off, not assuming it in this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:44:11 +05:30
niteshandClaude Opus 5 20fe11e60d fix(deposits): correct FD reinvestment on maturity and unblock product edit submission
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (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 - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract 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
Reinvestment on maturity was creating the new deposit with a zero balance, because it
sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to
stop a manually opened FD from auto-crediting itself. It now carries over what reInvest()
actually put on the new account. Both legs of the renewal are also posted as an internal
transfer, so the funds move through the transfer-suspense GL instead of appearing to leave
and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no
chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the
renewed term.

Adds a reinvestment trail: the new account records which account it came from, and the
account view resolves both directions of the link so either side can be traced.

Also fixes product edit submission, which failed outright for any product carrying
payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload
carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving
gl_account_id null and tripping the satellite table's NOT NULL constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:57:02 +05:30
103 changed files with 10506 additions and 1216 deletions
@@ -437,6 +437,7 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
@@ -2897,6 +2897,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -39,6 +39,7 @@ public enum JobName {
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}
@@ -0,0 +1,185 @@
/**
* 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.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
* how much is still unclaimed, and how much has come back to profit.
*
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
* money moved; the locks are what it moved for.
*
* The columns mirror the money's actual path, which begins and ends in the same place:
*
* <pre>
* declare Dr Bank Profit Cr Dividend Payable
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
* </pre>
*
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
* is what is still sitting unclaimed and still within its three-year window.
*/
@Path("/v1/accounting/dividend-yearwise-report")
@Component
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
@RequiredArgsConstructor
public class DividendYearwiseReportApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String report() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
// through the per-member detail rather than counted off the declaration, because a lock is only
// created for a member who actually had something to collect.
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
// Summing the declaration amount across a join to its locks would multiply it by the number
// of members it was split between.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
WITH fy AS (
SELECT d.id,
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
d.amount
FROM m_share_product_dividend_pay_out d
),
declared AS (
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
FROM fy GROUP BY fy_start
),
outcomes AS (
SELECT fy.fy_start,
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
FROM fy
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
GROUP BY fy.fy_start
)
SELECT d.fy_start, d.declaration_count, d.declared_amount,
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
o.earliest_release_due
FROM declared d
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
ORDER BY d.fy_start DESC
""");
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
// member, so no lock was ever created for it. Pulled separately and matched by year.
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
THEN EXTRACT(YEAR FROM period_end_date)
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
COALESCE(SUM(residue_amount), 0) AS residue
FROM m_share_dividend_rounding_residue
GROUP BY fy_start
""")) {
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
}
final List<Map<String, Object>> years = new ArrayList<>();
BigDecimal totalDeclared = BigDecimal.ZERO;
BigDecimal totalClaimed = BigDecimal.ZERO;
BigDecimal totalUnclaimed = BigDecimal.ZERO;
BigDecimal totalForfeited = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("financialYear", financialYear);
entry.put("declarationCount", row.get("declaration_count"));
entry.put("declaredAmount", declared);
entry.put("claimedAmount", claimed);
entry.put("claimedCount", row.get("claimed_count"));
entry.put("unclaimedAmount", unclaimed);
entry.put("unclaimedCount", row.get("unclaimed_count"));
entry.put("forfeitedAmount", forfeited);
entry.put("forfeitedCount", row.get("forfeited_count"));
entry.put("roundingResidueAmount", residue);
// What has already gone back to profit for this year: forfeited entitlements plus the
// rounding residue swept at declaration time.
entry.put("returnedToProfitAmount", forfeited.add(residue));
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
// amount starts returning to profit.
entry.put("earliestReleaseDueDate",
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
years.add(entry);
totalDeclared = totalDeclared.add(declared);
totalClaimed = totalClaimed.add(claimed);
totalUnclaimed = totalUnclaimed.add(unclaimed);
totalForfeited = totalForfeited.add(forfeited);
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("years", years);
response.put("totalDeclared", totalDeclared);
response.put("totalClaimed", totalClaimed);
response.put("totalUnclaimed", totalUnclaimed);
response.put("totalForfeited", totalForfeited);
return gson.toJson(response);
}
private String financialYearLabel(final int startYear) {
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
}
@@ -0,0 +1,278 @@
/**
* 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.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* How much profit a share dividend may actually be declared out of, for a given period.
*
* A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve
* Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared
* for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one
* place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
*
* Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration
* row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error.
*/
@Path("/v1/accounting/distributable-profit")
@Component
@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.")
@RequiredArgsConstructor
public class DistributableProfitApiResource {
/**
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
* IncomeProfitTransferService.PROFIT_GL_CODE.
*
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
* offer profit already distributed once for distribution again.
*/
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("excludeProductId") final Long excludeProductId) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
if (to.before(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
// A dividend is normally declared for a whole, closed financial year, and then the audited
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
// only be an interim declaration, which necessarily runs against the still-open P&L.
final String financialYear = financialYearOf(from.toLocalDate());
final LocalDate[] fyRange = financialYearRange(financialYear);
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
final boolean yearClosed = closedProfit != null;
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
// Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate
// and nothing to distribute, so the whole waterfall collapses to zero rather than producing
// a negative "reserve" that would read as money owed back to the society.
final boolean profitable = netProfit.signum() > 0;
final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO;
final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO;
final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount)
: BigDecimal.ZERO;
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
BigDecimal alreadyDeclared = BigDecimal.ZERO;
for (final Map<String, Object> declaration : existingDeclarations) {
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
}
// An over-declaration that already happened (or appropriation rates raised after the fact)
// must not present as negative headroom, which a caller could read as a credit.
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("fromDate", fromDate);
response.put("toDate", toDate);
response.put("financialYear", financialYear);
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
response.put("yearClosed", yearClosed);
// Names the figure so the screen can say where it came from — the closed books, or a P&L
// that is still moving — rather than showing a bare number the officer has to interpret.
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
response.put("netProfit", netProfit);
response.put("statutoryReservePercentage", statutoryReservePercentage);
response.put("statutoryReserveAmount", statutoryReserveAmount);
response.put("educationFundPercentage", educationFundPercentage);
response.put("educationFundAmount", educationFundAmount);
response.put("distributableProfit", distributableProfit);
response.put("alreadyDeclared", alreadyDeclared);
response.put("availableForDeclaration", availableForDeclaration);
response.put("maximumRatePercentage", maximumRatePercentage);
response.put("existingDeclarations", existingDeclarations);
return gson.toJson(response);
}
/**
* The year's profit as the books actually record it, once the year has been closed.
*
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
*
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
* figure that is still moving.
*/
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount == null || closedCount == 0) {
return null;
}
final LocalDate[] range = financialYearRange(financialYear);
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
private LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (final RuntimeException e) {
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
}
}
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
private String financialYearOf(final LocalDate date) {
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
*/
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
return totalIncome.subtract(totalExpense);
}
/**
* Every dividend already declared against a period overlapping this one, across ALL share products — the
* appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable
* profit as the first.
*
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
* declaration is separately blocked outright.
*/
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
final StringBuilder sql = new StringBuilder("""
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
d.dividend_period_start_date, d.dividend_period_end_date, d.status
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
""");
final List<Object> params = new ArrayList<>(List.of(to, from));
if (excludeProductId != null) {
sql.append(" AND d.product_id <> ? ");
params.add(excludeProductId);
}
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
final List<Map<String, Object>> declarations = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> declaration = new LinkedHashMap<>();
declaration.put("declarationId", row.get("declaration_id"));
declaration.put("productId", row.get("product_id"));
declaration.put("productName", row.get("product_name"));
declaration.put("amount", row.get("amount"));
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
declaration.put("status", row.get("status"));
declarations.add(declaration);
}
return declarations;
}
/**
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
* which is 0 — not a failure. Only an enabled row with a value applies.
*/
private BigDecimal configuredPercentage(final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) {
return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,180 @@
package org.apache.fineract.accounting.yearend.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
*
* <p>
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
*/
@Path("/v1/accounting/incomeprofittransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class IncomeProfitTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final IncomeProfitTransferService service;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// GET — how much is waiting to be posted, and what has been posted before
// ══════════════════════════════════════════════════════════════════════
@GET
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
@QueryParam("financialYear") final String financialYearParam) {
this.context.authenticatedUser();
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: incomeGlCodeParam.trim();
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
: financialYearParam.trim();
final LocalDate[] range = service.financialYearRange(financialYear);
final Long incomeGlId = toLong(incomeGl.get("id"));
final BigDecimal pending = service.balanceOf(incomeGlId, range);
final int entryCount = service.entryCountOf(incomeGlId, range);
final List<Map<String, Object>> history = jdbcTemplate
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("pendingAmount", pending);
out.put("entryCount", entryCount);
out.put("alreadyPosted", alreadyPosted);
out.put("postedHistory", history);
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — close the income GL into Bank Profit
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response post(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: getString(json, "incomeGlCode").trim();
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
// error, so the user is told why nothing happened.
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /close-year — close the WHOLE P&L into Bank Profit
// ══════════════════════════════════════════════════════════════════════
/**
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
*
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
*/
@POST
@Path("/close-year")
@Transactional
public Response closeYear(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
// told why nothing happened rather than getting a silent success.
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class PostShareEntryFeeToProfitConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private IncomeProfitTransferService incomeProfitTransferService;
@Bean
protected Step postShareEntryFeeToProfitStep() {
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
}
@Bean
public Job postShareEntryFeeToProfitJob() {
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
}
}
@@ -0,0 +1,61 @@
/**
* 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.accounting.yearend.jobs.postshareentryfeetoprofit;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
* Profit equity GL, leaving the income account at nil for the new year.
*
* <p>
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
* rather than failing the job. Only a genuine posting failure is raised.
*/
@Slf4j
@RequiredArgsConstructor
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
private static final long SYSTEM_USER_ID = 1L;
private final IncomeProfitTransferService incomeProfitTransferService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final String financialYear = incomeProfitTransferService.currentFinancialYear();
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
// is now what the share dividend declaration reads as "the year's profit", and a partial close
// would leave most of the profit still sitting in the income GLs — understating what may be
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
"Automatic year-end closing", false);
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,377 @@
package org.apache.fineract.accounting.yearend.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes an income GL into the Bank Profit equity GL at financial year end.
*
* <pre>
* Dr Share Entry Fee Income (10.4) = balance for the year
* Cr Bank Profit (BANKPROFIT01)
* </pre>
*
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
* retained profit.
*
* <p>
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
* logged-in user.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IncomeProfitTransferService {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
/** Equity GL that accumulates profit. */
/**
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
*
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
* decision — offering the same money for distribution twice.
*/
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
/** Income GL closed to profit by the year-end job — the share entry fee. */
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
private final JdbcTemplate jdbcTemplate;
/**
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
* errors.
*
* @param strict
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
* returns quietly.
*/
@Transactional
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
final String note, final boolean strict) {
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long incomeGlId = toLong(incomeGl.get("id"));
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, incomeGlId, officeId);
if (existing != null && existing > 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "Already closed for " + financialYear);
return out;
}
final BigDecimal balance = balanceOf(incomeGlId, range);
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "No income to close for " + financialYear);
return out;
}
final int entryCount = entryCountOf(incomeGlId, range);
final String currencyCode = resolveCurrency(incomeGlId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YEPROFIT-" + System.nanoTime();
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + incomeGl.get("name"));
jdbcTemplate.update(
"INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
financialYear);
out.put("posted", true);
out.put("amountPostedToProfit", balance);
out.put("entryCount", entryCount);
out.put("postedOnDate", postingDate.toString());
out.put("glTransactionId", txnId);
out.put("currency", currencyCode);
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
return out;
}
/**
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
* net profit.
*
* <pre>
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
* </pre>
*
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
* profit split across two places and understates it.
*
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
* Accounts with a nil balance are skipped rather than posted as zero entries.
*/
@Transactional
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
final boolean strict) {
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
// historical balance but must not attract new postings.
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
final List<Map<String, Object>> closed = new ArrayList<>();
final List<Map<String, Object>> skipped = new ArrayList<>();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
for (final Map<String, Object> gl : plAccounts) {
final Long glId = toLong(gl.get("id"));
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, glId, officeId);
if (existing != null && existing > 0) {
skipped.add(skipRow(gl, "Already closed for " + financialYear));
continue;
}
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
// expense GL the natural balance is a debit, so the same figure comes back negative and
// is flipped here to read as a positive expense.
final BigDecimal rawBalance = balanceOf(glId, range);
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
continue;
}
final int entryCount = entryCountOf(glId, range);
final String currencyCode = resolveCurrency(glId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
if (isIncome) {
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + gl.get("name"));
totalIncome = totalIncome.add(balance);
} else {
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
"Expense for " + financialYear + " from " + gl.get("name"));
totalExpense = totalExpense.add(balance);
}
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
java.sql.Date.valueOf(postingDate), userId, userName, note);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("type", isIncome ? "INCOME" : "EXPENSE");
row.put("amount", balance);
row.put("entryCount", entryCount);
row.put("glTransactionId", txnId);
closed.add(row);
}
if (closed.isEmpty() && strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
"There is nothing left to close to profit for " + financialYear + ".");
}
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
netProfit, profitGl.get("name"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("totalIncomeClosed", totalIncome);
out.put("totalExpenseClosed", totalExpense);
out.put("netProfitPosted", netProfit);
out.put("accountsClosed", closed);
out.put("accountsSkipped", skipped);
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
+ netProfit + ".");
return out;
}
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("reason", reason);
return row;
}
/**
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
* year has been closed.
*
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
* whether that is a blocking condition.
*/
public BigDecimal profitForFinancialYear(final String financialYear) {
final LocalDate[] range = financialYearRange(financialYear);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** Whether the year's P&L has been closed into Bank Profit at all. */
public boolean isYearClosed(final String financialYear) {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
Integer.class, financialYear);
return count != null && count > 0;
}
/** Net credit balance sitting in the income GL for the year. */
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** How many fee postings made up that balance. */
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return count == null ? 0 : count;
}
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
public String currentFinancialYear() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/** 1 April to 31 March for a "YYYY-YY" label. */
public LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
"financialYear must look like 2026-27. Received: " + financialYear);
}
}
public Map<String, Object> loadGlByCode(final String glCode) {
try {
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
}
}
public Long headOfficeId() {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
}
private String resolveCurrency(final Long glAccountId) {
try {
final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
glAccountId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -0,0 +1,299 @@
/**
* 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.accountregister.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Account register for one date: which accounts were opened and which were closed that day, with
* each account's balance as it stood on that date alongside what it holds now.
*
* The dated balance is read from the running balance stamped on the last transaction up to that
* date, rather than recomputed from the account's current figure — a current balance tells you
* nothing about what the account held three weeks ago.
*/
@Path("/v1/accountregister")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AccountRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
this.context.authenticatedUser();
final LocalDate asOn;
if (dateParam != null && !dateParam.isBlank()) {
try {
asOn = LocalDate.parse(dateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
"Date must be in yyyy-MM-dd format.");
}
} else {
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
final List<Map<String, Object>> rows = new ArrayList<>();
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
rows.addAll(depositAccounts(asOn, want));
}
if (matches(want, "SHARE")) {
rows.addAll(shareAccounts(asOn));
}
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
// so the totals can never disagree with the list they summarise.
int opened = 0;
int closed = 0;
BigDecimal balanceOnDate = BigDecimal.ZERO;
BigDecimal currentBalance = BigDecimal.ZERO;
for (final Map<String, Object> r : rows) {
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
opened++;
}
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
closed++;
}
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("date", asOn.toString());
out.put("section", want);
out.put("accounts", rows);
out.put("count", rows.size());
out.put("openedCount", opened);
out.put("closedCount", closed);
out.put("totalBalanceOnDate", balanceOnDate);
out.put("totalCurrentBalance", currentBalance);
return gson.toJson(out);
}
private static boolean matches(final String want, final String section) {
return "ALL".equals(want) || want.equals(section);
}
private static BigDecimal dec(final Object o) {
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
}
/**
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
*
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
* them would make a register of a past day describe a present one.
*/
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
final Date date = Date.valueOf(asOn);
final StringBuilder typeFilter = new StringBuilder();
if (!"ALL".equals(want)) {
final int type = switch (want) {
case "SAVINGS" -> 100;
case "FD" -> 200;
case "RD" -> 300;
default -> 0;
};
if (type > 0) {
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
}
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
sa.account_balance_derived,
c.display_name AS client_name,
p.name AS product_name,
(SELECT t.running_balance_derived
FROM m_savings_account_transaction t
WHERE t.savings_account_id = sa.id
AND COALESCE(t.is_reversed, false) = false
AND t.transaction_date <= ?
ORDER BY t.transaction_date DESC, t.id DESC
LIMIT 1) AS balance_on_date
FROM m_savings_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_savings_product p ON p.id = sa.product_id
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
""" + typeFilter + """
ORDER BY sa.deposit_type_enum, sa.account_no
""", date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
final int depositType = num(r.get("deposit_type_enum"));
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", savingsStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activatedon_date")));
m.put("closedOn", str(r.get("closedon_date")));
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
// No transaction up to that date means the account had nothing in it yet, not that the
// balance is unknown.
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
m.put("currentBalance", r.get("account_balance_derived") == null
? BigDecimal.ZERO : r.get("account_balance_derived"));
out.add(m);
}
return out;
}
/**
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
* are individually dated, so the holding on a given date is the shares purchased up to that
* date less those redeemed — which is a real dated figure, not the current one relabelled.
*/
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
final Date date = Date.valueOf(asOn);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
sa.total_approved_shares,
c.display_name AS client_name,
p.name AS product_name,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)) AS share_value,
-- Purchases less redemptions dated on or before the register's date. Charge
-- rows (type 700) are excluded: an entry fee is not share capital. Only
-- approved purchases count (status 300) — a rejected application leaves its
-- row behind still flagged active, and counting it credited a member with
-- shares they were never issued.
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS value_on_date,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
WHEN t.type_enum = 600 THEN -t.total_shares
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS shares_on_date
FROM m_share_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product p ON p.id = sa.product_id
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
ORDER BY sa.account_no
""", date, date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", "SHARE");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", shareStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activated_date")));
m.put("closedOn", str(r.get("closed_date")));
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
// Shares as at the date, not the approved figure — an account can hold fewer than were
// approved once some have been redeemed.
m.put("sharesHeld", r.get("shares_on_date"));
out.add(m);
}
return out;
}
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
return dbDate != null && asOn.toString().equals(dbDate.toString());
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
private static int num(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
private static String savingsStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
case 700 -> "TRANSFER IN PROGRESS";
case 800 -> "TRANSFER ON HOLD";
case 900 -> "PREMATURELY CLOSED";
default -> "UNKNOWN";
};
}
private static String shareStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
default -> "UNKNOWN";
};
}
}
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
@@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
* transaction that has already succeeded. Joining the caller's transaction would let a failed
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
* contained to AML.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
@@ -0,0 +1,477 @@
/**
* 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.branchday.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Branch day register — the formal opening and closing of a branch's business day.
*
* A day that was never closed is the thing this exists to surface: while it stays open its balance
* cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash
* difference on that date goes unnoticed. The register records who opened and closed each day, and
* lists the ones still hanging.
*
* Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it
* was left open, so "closed that evening" and "closed nine days later" stay distinguishable.
*/
@Path("/v1/branchday")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
/**
* The register for one office, newest first. Defaults to the caller's own office so a branch
* user sees their own branch without having to know its id.
*/
@GET
public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
FROM m_branch_day_register r
LEFT JOIN m_office o ON o.id = r.office_id
WHERE r.office_id = ?
ORDER BY r.business_date DESC
LIMIT ?
""", officeId, limit);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
result.put("canOperate", canOperateDay(currentUser));
result.put("days", mapDays(rows));
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
result.put("openDay", findOpenDay(officeId));
return gson.toJson(result);
}
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
/**
* Business days that were opened and never closed, oldest first — the ones that need attention
* most. Today's own open day is excluded: it is open because the day is still running, which is
* not a missed close.
*/
@GET
@Path("missed")
public String missed(@QueryParam("officeId") final Long officeIdParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
FROM m_branch_day_register r
LEFT JOIN m_office o ON o.id = r.office_id
WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ?
ORDER BY r.business_date ASC
""", officeId, Date.valueOf(today));
final List<Map<String, Object>> days = mapDays(rows);
// Age in days is what makes the list actionable — a day open since last week is a different
// problem from one open since yesterday.
for (final Map<String, Object> d : days) {
final Object bd = d.get("businessDate");
d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today));
}
return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days,
"count", days.size()));
}
// ── GET /v1/branchday/close-preview ──────────────────────────────────────
/**
* What the day should close at, with the arithmetic that produces it, plus the denomination
* total the cashier actually keyed in during the day.
*
* Exists so nobody has to work the figure out by hand or type it from memory: the screen shows
* where every line came from, and offers the counted total as the closing figure. A day is
* still closed on what someone physically counted — this only removes the guesswork about what
* that count is supposed to match.
*/
@GET
@Path("close-preview")
public String closePreview(@QueryParam("officeId") final Long officeIdParam,
@QueryParam("businessDate") final String businessDateParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate businessDate;
if (businessDateParam != null && !businessDateParam.isBlank()) {
try {
businessDate = LocalDate.parse(businessDateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
"Business date must be in yyyy-MM-dd format.");
}
} else {
final Map<String, Object> open = findOpenDay(officeId);
if (open == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
"No business day is open for this branch.");
}
businessDate = LocalDate.parse(String.valueOf(open.get("businessDate")));
}
final Map<String, Object> result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate));
result.put("officeId", officeId);
result.put("businessDate", businessDate.toString());
// What the cashier's own denomination entries add up to for the day. Where this and the
// expected figure agree, the day tallies and the closing figure is not in question.
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
WHERE txn_date = ? AND txn_type = 'DEPOSIT'
""", BigDecimal.class, Date.valueOf(businessDate)));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
""", BigDecimal.class, Date.valueOf(businessDate)));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
return gson.toJson(result);
}
// ── POST /v1/branchday/open ───────────────────────────────────────────────
@POST
@Path("open")
@Consumes(MediaType.APPLICATION_JSON)
public String openDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser);
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
// A branch runs one day at a time. Opening a second while the first is still open is how a
// day gets stranded, so the previous one has to be closed first.
final Map<String, Object> open = findOpenDay(officeId);
if (open != null) {
final Object existing = open.get("businessDate");
if (businessDate.toString().equals(String.valueOf(existing))) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.open",
"Business day " + businessDate + " is already open for this branch.");
}
throw new PlatformDataIntegrityException("error.msg.branchday.previous.open",
"Business day " + existing + " is still open. Close it before opening " + businessDate + ".");
}
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
Integer.class, officeId, Date.valueOf(businessDate));
if (exists != null && exists > 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
"Business day " + businessDate + " has already been opened and closed for this branch.");
}
// Yesterday's closing is today's opening unless the caller states otherwise, so the carried
// figure does not have to be retyped and cannot drift.
final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate);
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_branch_day_register
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
VALUES (?,?,?,?,?,?,false)
RETURNING id
""",
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
Timestamp.from(Instant.now()), carried);
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
}
// ── POST /v1/branchday/close ──────────────────────────────────────────────
@POST
@Path("close")
@Consumes(MediaType.APPLICATION_JSON)
public String closeDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser);
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate", null);
if (businessDate == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.required",
"Which business day is being closed?");
}
final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
final List<Map<String, Object>> found = jdbcTemplate.queryForList(
"SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
officeId, Date.valueOf(businessDate));
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
"Business day " + businessDate + " was never opened for this branch.");
}
if (found.get(0).get("closed_at") != null) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
"Business day " + businessDate + " is already closed.");
}
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
// but it must say why it was left open, and it is stamped as a force close.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final boolean isLate = businessDate.isBefore(today);
if (isLate && (forceReason == null || forceReason.isBlank())) {
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
"Business day " + businessDate + " is being closed late. Record why it was left open.");
}
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
jdbcTemplate.update("""
UPDATE m_branch_day_register
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
WHERE office_id = ? AND business_date = ?
""",
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
officeId, Date.valueOf(businessDate));
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
res.put("forceClosed", isLate);
return gson.toJson(res);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<Map<String, Object>> mapDays(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
m.put("officeId", r.get("office_id"));
m.put("officeName", r.get("office_name"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
m.put("closedByName", r.get("closed_by_name"));
m.put("closedAt", str(r.get("closed_at")));
m.put("closingCash", r.get("closing_cash"));
m.put("bookCash", r.get("book_cash"));
m.put("cashDifference", r.get("cash_difference"));
m.put("forceClosed", r.get("force_closed"));
m.put("forceCloseReason", r.get("force_close_reason"));
m.put("remarks", r.get("remarks"));
m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED");
out.add(m);
}
return out;
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
/** The branch's currently open day, or null when none is open. */
private Map<String, Object> findOpenDay(final Long officeId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash
FROM m_branch_day_register
WHERE office_id = ? AND closed_at IS NULL
ORDER BY business_date ASC LIMIT 1
""", officeId);
if (rows.isEmpty()) {
return null;
}
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
return m;
}
/** The most recent closing figure before this date, so it carries forward as the opening. */
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
final List<BigDecimal> rows = jdbcTemplate.query("""
SELECT closing_cash FROM m_branch_day_register
WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
ORDER BY business_date DESC LIMIT 1
""", (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before));
return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0);
}
/**
* Cash the books say the branch should be holding at the end of that date: what came in as
* approved cash deposits less what went out as approved cash withdrawals. Deliberately derived
* here and then stored on the row, so the figure the day was closed against survives any later
* reversal.
*/
private Map<String, Object> bookBreakdown(final Long officeId, final LocalDate businessDate) {
final Date date = Date.valueOf(businessDate);
// The figure recorded when this very day was opened — not the previous day's closing.
// Deriving it from history alone ignored an opening balance keyed in by hand, which is
// exactly what happens on the first day a branch is put on the system: the day was opened
// with 20,000 in the drawer and the closing arithmetic started from zero.
final BigDecimal ownOpening = jdbcTemplate.query(
"SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
""", BigDecimal.class, date, officeId));
final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
""", BigDecimal.class, date));
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, date));
// Cash a cashier settled back to the vault at the end of the day.
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, date));
final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("openingCash", opening);
out.put("deposits", deposits);
out.put("vaultIn", vaultIn);
out.put("withdrawals", withdrawals);
out.put("settledOut", settledOut);
out.put("expectedCash", expected);
return out;
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private Long resolveOffice(final Long requested, final AppUser user) {
if (requested != null) {
return requested;
}
return user.getOffice() != null ? user.getOffice().getId() : 1L;
}
private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) {
final String raw = this.fromJsonHelper.extractStringNamed(field, root);
if (raw == null || raw.isBlank()) {
return fallback;
}
try {
return LocalDate.parse(raw);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
"Business date must be in yyyy-MM-dd format.");
}
}
/**
* Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the
* same authority as the manager here by design — a day left open because the only manager was
* away is exactly the situation this register exists to prevent.
*
* A cashier is excluded: the cashier works inside the day and must not be the one who declares
* it closed.
*/
private boolean canOperateDay(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk"));
}
private void requireDayAuthority(final AppUser user) {
if (!canOperateDay(user)) {
throw new PlatformDataIntegrityException("error.msg.branchday.forbidden",
"Only a Clerk, Head Cashier or Bank Manager can open or close a business day.");
}
}
}
@@ -32,6 +32,179 @@ public class CashReportApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* Day book — every voucher raised on one date, in one list, newest first.
*
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
* without opening three separate screens.
*
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
* has no such column — falls back to the date it was submitted on.
*/
@GET
@Path("day-book")
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final List<Map<String, Object>> rows = new ArrayList<>();
// Each register is left-joined to the verification table and to an attachment count, so a
// row already carries its physical-check verdict and whether a slip was scanned in — the
// screen never has to fire a follow-up call per voucher.
final String verifyJoin = """
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = r.voucher_no
""";
final String verifyCols = """
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
vv.verified_by_name, vv.verified_at,
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
COALESCE(va.n, 0) AS attachment_count
""";
// Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_cashier_deposit_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_withdrawal_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
r.description, r.status, r.clerk_name AS handled_by,
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_disbursement_request r
""" + verifyJoin + """
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
// ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
continue;
}
filtered.add(row);
// Only approved money is real money: a pending or rejected voucher must not inflate the
// day's totals, even though it still belongs in the listing.
if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
creditTotal = creditTotal.add(amount);
} else {
debitTotal = debitTotal.add(amount);
}
}
}
}
filtered.sort((a, b) -> {
final Object left = b.get("submittedAt");
final Object right = a.get("submittedAt");
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.toString().compareTo(right.toString());
});
final Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", filtered);
result.put("count", filtered.size());
result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(debitTotal));
return gson.toJson(result);
}
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no"));
row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no"));
row.put("accountType", rs.getString("account_type"));
row.put("party", rs.getString("party"));
row.put("amount", rs.getBigDecimal("amount"));
row.put("paymentTypeName", rs.getString("payment_type_name"));
row.put("description", rs.getString("description"));
row.put("status", rs.getString("status"));
row.put("handledBy", rs.getString("handled_by"));
row.put("approvedBy", rs.getString("approved_by"));
row.put("txnId", rs.getString("txn_id"));
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
row.put("verifyStatus", rs.getString("verify_status"));
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
row.put("verifyRemarks", rs.getString("verify_remarks"));
row.put("verifiedByName", rs.getString("verified_by_name"));
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
// How the exception ended, if it has. Without this the day book keeps showing a closed
// exception as though it were still open — the flag never clears on screen.
row.put("resolutionStatus", rs.getString("resolution_status"));
row.put("resolutionNotes", rs.getString("resolution_notes"));
row.put("resolvedByName", rs.getString("resolved_by_name"));
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
row.put("attachmentCount", rs.getInt("attachment_count"));
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
// form the browser cannot parse reliably.
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row;
}
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
@@ -0,0 +1,754 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
*
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
* flow.
*
* <p>
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
*
* <pre>
* Dr Cash at Vault (SHARES_REFERENCE) = amount
* Cr Share Sundry (SHARES_SUSPENSE) = amount
* </pre>
*
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
*
* <p>
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
*
* <pre>
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
* Cr Share Capital (SHARES_EQUITY) = share amount
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
* </pre>
*
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
*
* <p>
* <b>When the cash does not match</b>
*
* <p>
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
* argued with at the window:
*
* <ul>
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
* back to them.</li>
* </ul>
*/
@Path("/v1/shareadvancecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareAdvanceCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
// ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_ACTIVE = 300;
// ── GL account the cashier deposits share money into ──────────────────
private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Take cash from a member with no share account yet
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long clientId = getLong(json, "clientId");
final BigDecimal amount = getBigDecimal(json, "amount");
final String note = sanitiseNote(getString(json, "note"));
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
}
final Map<String, Object> client = loadClient(clientId);
final Long officeId = toLong(client.get("office_id"));
final String clientName = (String) client.get("display_name");
// The share product drives the GL mapping. Collection happens before an account exists,
// so the product is supplied by the caller or defaults to the only mapped share product.
final Long productId = resolveProductId(getLong(json, "productId"));
final String currencyCode = resolveCurrency(productId);
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
"Share advance held in Sundry | Receipt " + receiptNo);
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
currentUser.getUsername(), note);
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
receiptNo);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", id);
out.put("receiptNo", receiptNo);
out.put("clientId", clientId);
out.put("clientName", clientName);
out.put("amount", amount);
out.put("currency", currencyCode);
out.put("status", "UNAPPLIED");
out.put("glTransactionId", txnId);
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
// firewall with a 400 before the request reaches this resource. The older
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
@GET
@Path("/by-voucher")
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
@QueryParam("clientId") final Long clientIdHint) {
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
"A voucher or receipt number is required.");
}
return findByVoucher(voucherNo, clientIdHint);
}
@GET
@Path("/by-voucher/{voucherNo}")
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
if (rows.isEmpty()) {
// Cash is normally taken through the cashier's Transaction Entry deposit into the Share
// Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a
// collection so the member's voucher resolves here.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint);
if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build();
}
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ voucherNo + ". Check the number, or confirm the deposit has been approved by a manager.");
}
final Map<String, Object> row = rows.get(0);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
}
return Response.ok(gson.toJson(row)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{collectionId}/apply")
@Transactional
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
// A member who pays in instalments has more than one receipt against the same application:
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
// are applied together as one settlement. The path receipt leads; any others come in the
// body.
final List<Long> collectionIds = new java.util.ArrayList<>();
collectionIds.add(collectionId);
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
if (!collectionIds.contains(additionalId)) {
collectionIds.add(additionalId);
}
}
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
for (final Long id : collectionIds) {
final Map<String, Object> row = loadCollection(id);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
}
collections.add(row);
}
final Map<String, Object> coll = collections.get(0);
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
}
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
"Share account " + acct.get("account_no") + " has already been settled.");
}
// Share value the account was opened for, and any entry fee attached to it.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
BigDecimal receiptAmount = BigDecimal.ZERO;
final StringBuilder receiptNos = new StringBuilder();
for (final Map<String, Object> row : collections) {
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
if (receiptNos.length() > 0) {
receiptNos.append(" + ");
}
receiptNos.append(row.get("receipt_no"));
}
// Short is blocked: share capital cannot be part-issued — the member either bought those
// shares or did not — so settling for less would leave the account short by an amount owed
// by nobody. The clerk collects the balance on another receipt and applies both together.
if (receiptAmount.compareTo(required) < 0) {
final BigDecimal shortfall = required.subtract(receiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall
+ " from the member, then apply both receipts together.");
}
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
// The difference is credited to their linked savings account below.
final BigDecimal excessAmount = receiptAmount.subtract(required);
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings "
+ "account, but none is linked to this share account. Link one, or collect the exact amount.");
}
final Long officeId = toLong(acct.get("office_id"));
final Long productId = toLong(acct.get("product_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
final String txnId = "SHAPP-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
// Dr Sundry for everything collected, including any over-payment — the liability those
// receipts created is cleared in full, in one leg, so nothing is left behind in suspense.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, when one was charged.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
}
// The over-payment goes back to the member as a savings deposit rather than a bare journal
// entry, so it lands in their passbook and running balance the way any counter deposit
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
Long excessSavingsTransactionId = null;
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
}
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
for (final Map<String, Object> row : collections) {
if (row.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO);
}
}
// Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use.
for (final Long id : collectionIds) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
}
// Reveals the purchase transaction in the passbook and transactions views.
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", collectionId);
out.put("collectionIds", collectionIds);
out.put("receiptNo", receiptNos.toString());
out.put("shareAccountId", shareAccountId);
out.put("accountNo", accountNo);
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalApplied", required);
out.put("totalCollected", receiptAmount);
out.put("glTransactionId", txnId);
out.put("status", "APPLIED");
// Told at the counter, while the member is still there, rather than left to be noticed on
// a statement later.
out.put("excessAmount", excessAmount);
if (excessSavingsTransactionId != null) {
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
+ "their savings account.");
}
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
*
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
* so the voucher resolves and can be applied.
*
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
* is what keeps the money tied to the right member.
*
* @return the newly created collection row, or null when no approved Sundry deposit matches.
*/
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) {
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " FROM m_cashier_deposit_request d"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, SHARE_SUNDRY_ACCOUNT_NO);
if (deposits.isEmpty()) {
return null;
}
final Map<String, Object> dep = deposits.get(0);
if (toLong(dep.get("share_account_id")) != null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// Prefer the member recorded on the deposit; fall back to the account being settled.
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
+ " so it cannot be matched. Re-enter the deposit against the member.");
}
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
private Long loadClientOfficeId(final Long clientId) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
}
/** Share value the account was opened for: the pending/approved purchase transaction. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding on the account, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
// created before the flat-charge fix carry the flag set to true while the full amount is
// still outstanding, and those charges must still be settled by the collection.
final BigDecimal total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadCollection(final Long id) {
try {
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
"Share advance collection " + id + " does not exist.");
}
}
private Map<String, Object> loadClient(final Long clientId) {
try {
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
}
}
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
/**
* Uses the caller's product when given, else the single mapped share product.
*
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
* when only one real share product is mapped.
*/
private Long resolveProductId(final Long requested) {
if (requested != null) {
return requested;
}
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
if (ids.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
"No share product has GL mappings configured.");
}
if (ids.size() > 1) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
"productId is required: " + ids.size() + " share products have GL mappings.");
}
return ids.get(0);
}
private String resolveCurrency(final Long productId) {
try {
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
productId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
return null;
}
}
private String nextReceiptNo() {
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
return String.format("SHADV%06d", seq == null ? 1 : seq);
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
}
}
private LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
try {
return LocalDate.parse(raw);
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
}
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
private List<Long> getLongList(final JsonObject json, final String field) {
final List<Long> values = new java.util.ArrayList<>();
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
return values;
}
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
if (!element.isJsonNull()) {
values.add(element.getAsLong());
}
}
return values;
}
/**
* Credits an over-payment to the member's savings account.
*
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
* would move the balance in the ledger while leaving the member's own account history silent about it.
*/
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
}
/**
* The payment type this deposit is recorded under.
*
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
*/
private Long resolveExcessPaymentTypeId() {
final List<Long> byName = jdbcTemplate.queryForList(
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
if (!byName.isEmpty()) {
return byName.get(0);
}
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
if (any.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
}
return any.get(0);
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number ? ((Number) o).intValue() : 0;
}
}
@@ -375,8 +375,20 @@ public class ShareCollectionApiResource {
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
toUtc(now),
toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private Map<String, Object> glEntryMap(final String account, final String type,
@@ -0,0 +1,601 @@
/**
* 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.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Settles a share application out of the member's own savings account.
*
* The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks
* in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks
* with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly:
*
* <pre>
* Dr Savings Control (share value + entry fee)
* Cr Share Capital (share value)
* Cr Entry Fee Income (charge, when one is attached)
* </pre>
*
* No Sundry leg, because nothing is ever held — the debit and the share issue happen together.
*
* Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval
* touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair
* of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves.
*
* The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and
* running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own
* Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and
* replaced with the share-capital pair above.
*/
@Path("/v1/sharesavingsdebit")
@Component
@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareSavingsDebitRequestApiResource {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
// acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource.
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
private static final int PRODUCT_TYPE_SHARES = 4;
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
private static final int SHARE_STATUS_ACTIVE = 300;
private static final int SAVINGS_STATUS_ACTIVE = 300;
/** m_payment_type.value seeded by changelog 0483 for this settlement. */
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
// ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ──────────────
/**
* Whether this share account can be settled from savings, and for how much.
*
* Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked,
* does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the
* counter instead of after a request has been raised and has to be rejected.
*/
@GET
@Path("/eligibility/{shareAccountId}")
public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("shareAccountId", shareAccountId);
out.put("shareAccountNo", acct.get("account_no"));
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalRequired", required);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
out.put("eligible", false);
out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead.");
return Response.ok(gson.toJson(out)).build();
}
final Long savingsAccountId = toLong(savingsAccountIdRaw);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
final BigDecimal available = balance == null ? BigDecimal.ZERO : balance;
out.put("savingsAccountId", savingsAccountId);
out.put("savingsAccountNo", savings.get("account_no"));
out.put("savingsBalance", available);
final String blocker = settlementBlocker(acct, savings, required, available);
out.put("eligible", blocker == null);
out.put("reason", blocker);
return Response.ok(gson.toJson(out)).build();
}
/**
* Why this share account cannot be settled from savings right now, or null when it can.
*
* Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement
* is allowed.
*/
private String settlementBlocker(final Map<String, Object> shareAccount, final Map<String, Object> savings,
final BigDecimal required, final BigDecimal available) {
if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) {
return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled.";
}
if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) {
return "Share account " + shareAccount.get("account_no") + " has already been settled.";
}
if (required.compareTo(BigDecimal.ZERO) <= 0) {
return "There is nothing outstanding on share account " + shareAccount.get("account_no") + ".";
}
// The savings account must belong to the same member — debiting anyone else's balance for
// these shares would be taking one member's money for another's holding.
if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) {
return "The linked savings account belongs to a different member.";
}
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
return "Savings account " + savings.get("account_no") + " is not Active.";
}
if (available.compareTo(required) < 0) {
return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required
+ " is required. Take a deposit first, or settle by cash collection.";
}
return null;
}
// ── POST /v1/sharesavingsdebit ── Clerk records the request ─────────────
@POST
@Transactional
public Response submit(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required.");
}
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings",
"Share account " + acct.get("account_no") + " has no linked savings account.");
}
final Long savingsAccountId = toLong(savingsAccountIdRaw);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
// Checked at submit as well as at approval: the clerk should be told now, not after the
// member has left and a manager finds the account short.
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
if (blocker != null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
}
final Long existingPending = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
shareAccountId);
if (existingPending != null && existingPending > 0) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending",
"A savings-debit request for this share account is already pending review.");
}
final Long newId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_savings_debit_request
(share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount,
currency_code, status, clerk_id, clerk_name, note)
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
""", Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required,
acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", newId);
out.put("status", "PENDING");
out.put("totalAmount", required);
return Response.ok(gson.toJson(out)).build();
}
// ── GET /v1/sharesavingsdebit ── Requests awaiting review ───────────────
@GET
public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name,
r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance,
r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status,
r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at,
r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note
FROM m_share_savings_debit_request r
JOIN m_share_account sa ON sa.id = r.share_account_id
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
WHERE 1 = 1
""");
final List<Object> params = new ArrayList<>();
if (!"ALL".equalsIgnoreCase(status)) {
sql.append(" AND r.status = ? ");
params.add(status.toUpperCase());
}
if (shareAccountId != null) {
sql.append(" AND r.share_account_id = ? ");
params.add(shareAccountId);
}
sql.append(" ORDER BY r.submitted_at DESC, r.id DESC");
return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build();
}
// ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ─────────
/**
* Debits the member's savings and issues the shares.
*
* Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the
* approval is the point at which it actually matters.
*/
@POST
@Path("/{id}/approve")
@Transactional
public Response approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
validateCheckerRole(currentUser);
final Map<String, Object> req = loadRequest(id);
if (!"PENDING".equals(req.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
"Only PENDING requests can be approved. This one is " + req.get("status") + ".");
}
if (toLong(req.get("clerk_id")).equals(currentUser.getId())) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval",
"Maker-checker: you cannot approve a request you submitted yourself. Ask another manager.");
}
final Long shareAccountId = toLong(req.get("share_account_id"));
final Long savingsAccountId = toLong(req.get("savings_account_id"));
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
// Recomputed rather than taken from the request: shares or charges may have been edited
// between submit and approval, and the member must be debited for what is actually owed.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
if (blocker != null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
}
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String note = "Share application settled from savings | " + accountNo;
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
// pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money
// became share capital, which the pair below records.
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHSAV-" + savingsTxnId;
insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application debited from savings " + savings.get("account_no") + " | " + accountNo);
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
}
// Marks the account settled, which is what stops it being settled again by either route.
this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
this.jdbcTemplate.update("""
UPDATE m_share_savings_debit_request
SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(),
share_amount = ?, charge_amount = ?, total_amount = ?,
savings_transaction_id = ?, gl_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("status", "APPROVED");
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalDebited", required);
out.put("savingsTransactionId", savingsTxnId);
return Response.ok(gson.toJson(out)).build();
}
// ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ───────────
@POST
@Path("/{id}/reject")
@Transactional
public Response reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> req = loadRequest(id);
if (!"PENDING".equals(req.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
"Only PENDING requests can be rejected. This one is " + req.get("status") + ".");
}
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body);
this.jdbcTemplate.update("""
UPDATE m_share_savings_debit_request
SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("status", "REJECTED");
return Response.ok(gson.toJson(out)).build();
}
// ── Helpers ────────────────────────────────────────────────────────────
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
}
/**
* The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the
* withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out.
* The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it
* on the member's statement and in payment-type reporting.
*
* Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles
* rather than failing at approval with money already half-moved.
*/
private Long resolveSettlementPaymentTypeId() {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
if (!rows.isEmpty()) {
return toLong(rows.get(0).get("id"));
}
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
if (fallback.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type",
"No payment types are configured. Add one before settling a share application from savings.");
}
return toLong(fallback.get(0).get("id"));
}
/** Savings Control GL for this savings account's product. */
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
/** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = this.jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
final BigDecimal total = this.jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadShareAccount(final Long shareAccountId) {
try {
return this.jdbcTemplate.queryForMap("""
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
sa.collection_applied, sa.savings_account_id, c.office_id
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?
""", shareAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
savingsAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found",
"Savings account " + savingsAccountId + " does not exist.");
}
}
private Map<String, Object> loadRequest(final Long id) {
try {
return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
"Savings-debit request " + id + " does not exist.");
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
try {
final Long glId = this.jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
} catch (final IncorrectResultSizeDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
this.jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
* own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
"Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
}
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
}
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private String getString(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
return null;
}
final String value = json.get(field).getAsString().trim();
return value.isEmpty() ? null : value;
}
private Long toLong(final Object o) {
return o instanceof Number n ? n.longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
}
@@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
// bank transfer both move money out of the source side, so they read as a debit.
final String voucherPrefix = "COLLECTION".equals(requestType)
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser);
newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId);
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
final String voucherNo = generateBtVoucherNo(logId);
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
@@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource {
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
/**
* Bank-transfer settlement voucher, drawn from the same central register as every other
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
* the debited accounts, so it reads as a debit: DR/001/2108/0001.
*/
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
return voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
@@ -58,36 +57,29 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker workflow for a member to claim an unclaimed dividend (see
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
* that was never credited into any savings account). A clerk (maker) records exactly how the
* member wants it split when they visit the branch: straight into an overdue loan, credited to
* their linked savings account, or handed over as cash -- any combination, any leftover amount
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
* Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status
* {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk
* (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the
* draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a
* later visit, and is forfeited to profit if three years pass.
*
* <p>
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
* which debits whatever GL account is actually configured as that product's Fund Source -- not
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
* that same DEBIT leg is rewritten in place to Dividend Payable (see
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
* post through, so approval
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
* physical hand-over is deferred: the claim's {@code cash_status} becomes
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
* submit/approve/disburse token flow for ordinary savings withdrawals.
* A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and
* loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member
* holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no
* counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on
* those historical rows — but new claims only ever write {@code dd_apply_amount}.
*
* <p>
* The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD
* number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter
* debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the
* member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue
* request, and why no charge or GST attaches to it.
*/
@Path("/v1/clerk/dividend-claim-requests")
@Component
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
@RequiredArgsConstructor
public class DividendClaimRequestApiResource {
@@ -98,44 +90,27 @@ public class DividendClaimRequestApiResource {
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
/**
* Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the
* member — so the register shows who actually raised it rather than repeating the beneficiary's name in both
* columns, which would read as a counter sale to the member.
*/
private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)";
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
"r.submitted_at", "r.actioned_at" };
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
@GET
@Path("/overdue-loans/{clientId}")
@Produces(MediaType.APPLICATION_JSON)
public String overdueLoans(@PathParam("clientId") final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
row.addProperty("accountNo", (String) loan.get("account_no"));
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
result.add(row);
}
return gson.toJson(result);
}
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount",
"r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at",
// The Demand Draft this claim is collected as — the only settlement route for new claims.
// loan/savings/cash columns above remain for claims settled before those routes were retired.
"r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id",
"r.dd_number" };
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@@ -147,18 +122,10 @@ public class DividendClaimRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
// while a dividend share often works out to paise -- floor to the nearest rupee (never
// trusting the frontend to have already done so) so the denomination breakdown the
// cashier counts at disburse time can always match exactly; any leftover paise is simply
// not claimed here and stays part of the lock's balance for a future claim.
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
.setScale(0, RoundingMode.FLOOR);
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root));
final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
if (lockId == null) {
throw apiError(400, "lockId is required");
}
@@ -171,53 +138,28 @@ public class DividendClaimRequestApiResource {
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final Object lockSavingsAccountId = lock.get("savings_account_id");
// The share account isn't required to have a linked savings account up front any more --
// if the member wants a savings credit and none is linked, the clerk supplies which
// account to credit right here.
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
: savingsAccountIdOverride;
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (resolvedSavingsAccountId == null) {
throw apiError(400,
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
}
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
resolvedSavingsAccountId);
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
}
if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "ddApplyAmount is required and must be greater than zero");
}
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(400,
"Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
if (ddBeneficiaryName == null) {
throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee");
}
if (ddPayableAtBranch == null || ddPayableAtCity == null) {
throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
}
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
}
if (total.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (loanId == null) {
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
}
final Map<String, Object> loan = jdbcTemplate
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
}
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
throw apiError(400, "Loan " + loanId + " is not Active");
}
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
if (tokNo == null || tokNo.isBlank()) {
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
}
validateTokenUniqueToday(tokNo, null);
// A draft can only be raised if there is stock to raise it on. Checked here rather than at
// approval so the clerk finds out while the member is still at the counter, instead of the
// claim sitting pending and failing later.
final Long availableLeaflets = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class);
if (availableLeaflets == null || availableLeaflets == 0) {
throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.");
}
final Long existingPending = jdbcTemplate.queryForObject(
@@ -226,40 +168,85 @@ public class DividendClaimRequestApiResource {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
validateNotGuarantorOfOverdueLoan(clientId);
// loan/savings/cash apply amounts are written as zero: those routes are retired, but the
// columns remain for claims settled through them historically.
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
(lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
status, clerk_id, clerk_name, submitted_at)
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
""", Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
* Ensures the token number is unique for today's date across both this table and
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
* Transaction Entry field, so the two pools must never collide.
* Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan.
*
* A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security
* behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed
* loan is in arrears hands over money the society may be about to demand back from the very same member, and once
* a draft is drawn there is nothing left to recover it from.
*
* Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has
* not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted
* on. The block lifts on its own the moment the borrower clears the arrears.
*
* Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell
* the member standing at the counter why — and so the member can go to the borrower about it.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
final List<Object> params = new ArrayList<>();
params.add(tokNo);
if (excludeId != null) {
sql.append(" AND id <> ?");
params.add(excludeId);
private void validateNotGuarantorOfOverdueLoan(final Long clientId) {
final List<Map<String, Object>> overdueGuarantees = jdbcTemplate.queryForList("""
SELECT l.account_no,
COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name,
COALESCE(SUM(ri.principal_amount + ri.interest_amount
- COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount
FROM m_guarantor g
JOIN m_loan l ON l.id = g.loan_id
LEFT JOIN m_client b ON b.id = l.client_id
JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id
WHERE g.entity_id = ?
AND g.type_enum = 1
AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
AND ri.completed_derived = false
AND ri.duedate < CURRENT_DATE
GROUP BY l.account_no, b.display_name
HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount
- COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.interest_completed_derived, 0)), 0) > 0
ORDER BY overdue_amount DESC
""", clientId);
if (overdueGuarantees.isEmpty()) {
return;
}
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
if (total > 0) {
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
final StringBuilder detail = new StringBuilder();
for (final Map<String, Object> guarantee : overdueGuarantees) {
if (detail.length() > 0) {
detail.append("; ");
}
detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name"))
.append(") overdue ").append(guarantee.get("overdue_amount"));
}
throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: "
+ detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends.");
}
private String trimToNull(final String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private BigDecimal parseAmount(final String value) {
@@ -317,373 +304,207 @@ public class DividendClaimRequestApiResource {
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
// 409 rather than 403: the user has every right to approve dividend claims — just not this
// one, because they raised it. A 403 reads as "you lack permission", which sends the checker
// to an administrator to fix access that was never the problem.
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a "
+ "payout: one to raise it, another to check it. Ask a different manager to approve this claim.");
}
final Long lockId = ((Number) record.get("lock_id")).longValue();
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst()
.orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
// Re-checked at approval, not just at submission: a claim can sit pending for days, and a
// guaranteed loan can fall into arrears in that window. Approval is the point the draft is
// actually drawn, so it is the last moment the society can still hold the money back.
validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue());
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
if (total.compareTo(unclaimedAmount) > 0) {
final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount");
if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(409, "This claim has no Demand Draft amount recorded.");
}
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(409,
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
}
final Object claimSavingsAccountId = record.get("savings_account_id");
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
final Map<String, Object> issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today,
currentUser);
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long loanId = ((Number) record.get("loan_id")).longValue();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", loanApplyAmount);
payload.addProperty("note", note);
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
}
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", savingsApplyAmount);
payload.addProperty("note", note);
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
}
final BigDecimal remainder = unclaimedAmount.subtract(total);
final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
}
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
cash_status = ?
dd_issue_request_id = ?, dd_number = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
response.addProperty("totalDisbursed", total);
response.addProperty("totalDisbursed", ddApplyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber"));
response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId"));
return gson.toJson(response);
}
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
* disbursing it, instead of the manager handing over cash directly at approval time.
* Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable
* liability rather than by anything the member pays in.
*
* A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they
* are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable
* and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those
* columns are written as zero.
*
* The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a
* real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other.
*/
@GET
@Path("/by-token/{token}")
@Produces(MediaType.APPLICATION_JSON)
public String getByToken(@PathParam("token") final String token) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
ORDER BY r.submitted_at DESC LIMIT 1
""", token);
private Map<String, Object> issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId,
final Map<String, Object> record, final BigDecimal amount, final String currencyCode, final LocalDate today,
final AppUser currentUser) {
if (rows.isEmpty()) {
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
}
// Oldest series first, and locked so two concurrent approvals cannot take the same leaflet.
final Map<String, Object> leaflet = jdbcTemplate.queryForList("""
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
""").stream().findFirst()
.orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory."));
final Long leafletId = ((Number) leaflet.get("id")).longValue();
final String ddNumber = (String) leaflet.get("dd_number");
final Map<String, Object> row = rows.get(0);
final String status = String.valueOf(row.get("status"));
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
final Long clientId = ((Number) record.get("client_id")).longValue();
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
if ("PENDING".equals(status)) {
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
}
if ("REJECTED".equals(status)) {
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
}
if (!"APPROVED".equals(status)) {
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
}
if ("DISBURSED".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
}
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
}
return gson.toJson(row);
}
// APPROVED outright: the manager approving this claim IS the check on it, and a second
// maker-checker round on the draft would leave a member's paid dividend in limbo.
// client_id still records WHOSE dividend this is — the entitlement belongs to them and the
// draft has to be traceable back to the member. walkin_purchaser_name names who actually
// raised it, which is the society, so the two roles stay distinguishable.
final Long issueRequestId = jdbcTemplate.queryForObject("""
INSERT INTO m_sponsor_dd_issue_request
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
payment_method, status, maker_id, created_date, checker_id, checker_date)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?)
RETURNING id
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"),
record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today),
((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(),
Timestamp.from(Instant.now()));
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
// The leaflet carries its own copy of the draft's details, and the DD register reads from
// there rather than from the issue request — so setting the status alone leaves the draft
// listed with every column blank. Mirrors what the ordinary issue flow writes.
//
// issued_to_client_id is deliberately left NULL and the purchaser named as the society
// instead. On an ordinary draft the client IS the purchaser — they walked in and bought it.
// Here the society is paying the member their own dividend, so recording the member as
// purchaser would read as though they had bought the draft, and the register would show the
// same name as both purchaser and beneficiary with nothing to distinguish a
// society-initiated draft from a counter sale.
jdbcTemplate.update("""
UPDATE m_sponsor_dd_leaflet
SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL,
walkin_purchaser_name = ?, beneficiary_name = ?,
payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?,
advice_status = 'NOT_SENT'
WHERE id = ?
""", amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"),
record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId);
/**
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
*/
@POST
@Path("/{id}/disburse")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String disburse(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
final Map<String, Object> record = jdbcTemplate
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
id)
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser);
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
}
String denominationData = null;
BigDecimal transactionAmount = null;
if (body != null && !body.isBlank()) {
final JsonElement root = fromJsonHelper.parse(body);
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
}
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
}
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
// but a claim created before that fix may still carry paise -- floor here too so an older
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
if (disbursableAmount.compareTo(transactionAmount) != 0) {
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
}
final long denomTotal = denominationTotal(denominationData);
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
throw apiError(400,
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
}
final int updated = jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
if (updated == 0) {
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
}
if (denominationData != null && !denominationData.isBlank()) {
final Long clientId = ((Number) record.get("client_id")).longValue();
final Long lockId = ((Number) record.get("lock_id")).longValue();
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
}
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
}
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
private long denominationTotal(final String denominationData) {
if (denominationData == null || denominationData.isBlank()) {
return 0;
}
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
throw apiError(400, "denominationData is not valid JSON");
}
if (denomMap == null) {
return 0;
}
long total = 0;
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final int value;
final int count;
try {
value = Integer.parseInt(parts[1]);
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count > 0) {
total += (long) value * count;
}
}
return total;
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("issueRequestId", issueRequestId);
result.put("ddNumber", ddNumber);
return result;
}
/**
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
* claim request's own id is used as the stable txn identifier for grouping.
* Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but
* now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and
* paid, through the normal DD settlement.
*
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
*/
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
final String denominationData, final AppUser cashier) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (final Exception e) {
return;
}
if (denomMap == null || denomMap.size() == 0) {
return;
}
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final int count;
try {
count = entry.getValue().getAsInt();
} catch (final Exception e) {
continue;
}
if (count <= 0) {
continue;
}
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) {
continue;
}
final String kind = parts[0];
final int value;
try {
value = Integer.parseInt(parts[1]);
} catch (final Exception e) {
continue;
}
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) {
continue;
}
final Long denomId = ids.get(0);
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
}
}
/**
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
* would break on any tenant seeded differently.
*/
private Long resolveDividendClaimPaymentTypeId() {
final Long paymentTypeId = jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long dividendPaidAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_PAID)
.stream().findFirst().orElse(null);
final Long ddPayableAccountId = jdbcTemplate
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
.findFirst().orElse(null);
if (paymentTypeId == null) {
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
}
return paymentTypeId;
}
/**
* The loan repayment / savings deposit command just posted its own native GL entries assuming
* the money came out of whichever account is that product's actual Fund Source (not always
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
* a second manual entry pair (which would leave that fund-source account appearing twice, once
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
* the two ids is non-null per call.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null) {
return;
}
if (loanTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
if (savingsTransactionId != null) {
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
}
/**
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
* member's hand.
*/
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate) {
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long cashAtVaultAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
if (payableDividendsAccountId == null || ddPayableAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
final String description = "Dividend claim #" + claimRequestId + " disbursed";
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
final String transactionId = "DIVDD" + claimId;
// Stamped with the financial year the dividend was DECLARED for, not the year it is being
// claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot
// say how much of each year's declaration has actually been claimed.
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
+ claimId + " (lock #" + lockId + ")";
if (dividendPaidAccountId == null) {
// Dividend Paid not mapped: post straight across, exactly as before this account existed.
// A society that has not configured it still gets a correct payout, just without the
// separate "how much has been claimed" measure.
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
currentUser);
return;
}
// Routed through Dividend Paid rather than posted straight across, so the society can read
// off what has been claimed and what is still unclaimed as two separate balances instead of
// only the net of Dividend Payable.
//
// Two pairs, in the order the money moves, each with its own description — reading Dividend
// Paid's ledger has to show which line settled the member's entitlement and which raised the
// draft, and one shared description makes the two indistinguishable.
postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate,
description + " (member's entitlement settled)", currentUser);
// The obligation does not end here: the society still owes the money until the draft is
// presented and paid, which is why this moves to Sponsor DD Payable rather than to cash.
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
description + " (draft raised)", currentUser);
}
/**
* Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction
* the money travelled and the ledger reads in order.
*/
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
final AppUser currentUser) {
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -691,13 +512,12 @@ public class DividendClaimRequestApiResource {
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount,
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount,
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@@ -742,6 +562,7 @@ public class DividendClaimRequestApiResource {
private Map<String, Object> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
@@ -751,6 +572,28 @@ public class DividendClaimRequestApiResource {
return rows.get(0);
}
/**
* The financial year of the declaration a lock came from — walking lock to per-member detail to declaration.
*
* Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being
* paid their dividend.
*/
private String financialYearOfLock(final Long lockId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT d.dividend_period_end_date
FROM m_share_dividend_lock l
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
WHERE l.id = ?
""", lockId);
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
return "unknown";
}
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
@@ -51,11 +51,10 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
* progress, full 3-year lock history, and members whose unclaimed dividend (see
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
* to be forfeited to profit after 3 years.
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year
* lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan
* installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or
* for it to be forfeited to profit after 3 years.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@@ -112,8 +111,7 @@ public class DividendDashboardApiResource {
final JsonObject result = new JsonObject();
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations",
((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue());
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
@@ -132,9 +130,9 @@ public class DividendDashboardApiResource {
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
/**
* Every dividend ever declared (across every share product), so a manager tracking multiple
* dividends at once can see -- per declaration -- how many members it covers and how far its
* payout has actually progressed, not just whether it was approved.
* Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can
* see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just
* whether it was approved.
*/
@GET
@Path("/declarations")
@@ -179,25 +177,34 @@ public class DividendDashboardApiResource {
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
/**
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
* profit). Optional {@code status} query param filters to one.
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
* member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
* years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one.
*/
@GET
@Path("/locks")
@Produces(MediaType.APPLICATION_JSON)
public String locks(@QueryParam("status") final String status) {
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
// Which declaration each lock came from. Once a member has more than one unclaimed dividend —
// this year's plus a prior year they never collected — amount and locked date alone cannot
// tell them apart, so the clerk has no way to say which year is being paid out. The join
// walks lock -> per-member detail -> declaration -> product to name the period and product.
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
+ "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " //
+ "FROM m_share_dividend_lock l " //
+ "JOIN m_client c ON c.id = l.client_id " //
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
+ "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
+ "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
+ "LEFT JOIN m_share_product p ON p.id = d.product_id " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
+ "ORDER BY l.locked_date DESC";
// Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
// needs attention, and it is the one a member should be paid out of first.
+ "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC";
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
@@ -217,6 +224,17 @@ public class DividendDashboardApiResource {
final Object appliedLoanId = row.get("applied_loan_id");
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
final Object declarationId = row.get("declaration_id");
entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
final Object periodStart = row.get("dividend_period_start_date");
final Object periodEnd = row.get("dividend_period_end_date");
entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString());
entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString());
// The financial year the dividend was earned in, taken from the period end so a
// declaration is labelled by the year it closes — "FY 2026-27", the way a member and an
// auditor both refer to it.
entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString()));
result.add(entry);
}
return gson.toJson(result);
@@ -260,17 +278,19 @@ public class DividendDashboardApiResource {
}
private JsonArray overdueLoansFor(final Long clientId) {
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""", clientId);
final List<Map<String, Object>> loans = jdbcTemplate.queryForList(
"""
SELECT ln.id AS loan_id, ln.account_no,
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
GROUP BY ln.id, ln.account_no
ORDER BY overdue_amount DESC
""",
clientId);
final JsonArray result = new JsonArray();
for (final Map<String, Object> loan : loans) {
final JsonObject row = new JsonObject();
@@ -285,14 +305,13 @@ public class DividendDashboardApiResource {
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
/**
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
* and file a claim. Since the dividend was never credited into any savings account (it's a
* pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
* native and correct) and then redirects the debit leg it assumed the cash came from to the
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see
* {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend
* was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a
* native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the
* debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a
* claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@@ -341,15 +360,14 @@ public class DividendDashboardApiResource {
payload.addProperty("transactionDate", today.toString());
payload.addProperty("transactionAmount", applyAmount);
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
.withJson(payload.toString()).build();
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString())
.build();
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
lockId);
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
@@ -366,16 +384,14 @@ public class DividendDashboardApiResource {
}
/**
* The loan repayment just posted above assumed the money came out of whichever account is
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
* leave that fund-source account appearing twice, once debited then immediately credited back
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
* Payable financial activity.
* The loan repayment just posted above assumed the money came out of whichever account is this loan product's
* actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product
* debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second
* manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately
* credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account
* in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect:
* Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if
* the bank hasn't mapped the Dividend Payable financial activity.
*/
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
final Long payableDividendsAccountId = jdbcTemplate
@@ -389,6 +405,16 @@ public class DividendDashboardApiResource {
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
}
/**
* The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending
* 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it.
*/
private String financialYearOf(final String isoDate) {
final LocalDate date = LocalDate.parse(isoDate);
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
@@ -440,6 +440,9 @@ public class FixedDepositAccountsApiResource {
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "payment")) {
final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "calculatePrematureAmount")) {
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
@@ -453,7 +456,7 @@ public class FixedDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
"postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -489,6 +492,8 @@ public class FixedDepositAccountsApiResource {
DepositAccountData account = null;
if (is(commandParam, "close")) {
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
} else if (is(commandParam, "payment")) {
account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
}
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
@@ -366,6 +366,15 @@ public class DepositAccountDataValidator {
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
@@ -529,6 +538,15 @@ public class DepositAccountDataValidator {
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
@@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator {
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.reinvestAmountParamName));
@Autowired
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
@@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator {
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isTransferToSavings()) {
if (accountOnClosureType.isPartialReinvest()) {
// Partial Re-Invest needs a kept-amount and a destination account that only the Payment
// command collects, and only its handler implements. Neither the FD nor the RD closure
// handler has a branch for it, so without this it would fall through to their terminal
// "withdraw the whole balance" case and silently pay the deposit out in cash.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"partial.reinvest.not.allowed",
"Partial Re-Invest is only supported through the Payment action, not account closure");
} else if (accountOnClosureType.isTransferToSavings()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
@@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is
* for Close/Premature Close, so this never changes either of their existing behavior. Unlike
* {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction --
* Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own
* amount and destination-account rules.
*/
public void validatePayment(final JsonCommand command) {
final String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(DepositAccountType.FIXED_DEPOSIT.resourceName());
final JsonElement element = command.parsedJson();
final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element);
baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull();
final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element);
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isWithdarwDeposit()) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage(
"withdraw.deposit.not.allowed.for.payment",
"Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option.");
}
// Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same
// destination-account rule Transfer to Savings and Partial Reinvest already need.
if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest()
|| accountOnClosureType.isReinvestPrincipal()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
}
if (accountOnClosureType.isPartialReinvest()) {
final BigDecimal reinvestAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull()
.positiveAmount();
}
}
// Validate all string payment detail fields for max length
final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element);
baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero();
final Set<String> paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName,
routingCodeParamName, receiptNumberParamName, bankNumberParamName));
for (final String paymentDetailParameterName : paymentDetailParameters) {
final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element);
baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull()
.notExceedingLengthOf(50);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
//
@@ -87,6 +87,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException;
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
@@ -97,9 +98,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -123,6 +126,7 @@ public class DepositAccountAssembler {
private final ExternalIdFactory externalIdFactory;
private final SavingsAccountAssembler savingsAccountAssembler;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Autowired
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
@@ -134,7 +138,8 @@ public class DepositAccountAssembler {
final RecurringDepositProductRepository recurringDepositProductRepository,
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
final SavingsAccountAssembler savingsAccountAssembler) {
final SavingsAccountAssembler savingsAccountAssembler,
final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) {
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
this.clientRepository = clientRepository;
@@ -151,6 +156,7 @@ public class DepositAccountAssembler {
this.paymentDetailAssembler = paymentDetailAssembler;
this.externalIdFactory = externalIdFactory;
this.savingsAccountAssembler = savingsAccountAssembler;
this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService;
}
/**
@@ -322,6 +328,28 @@ public class DepositAccountAssembler {
accountChart = DepositAccountInterestRateChart.from(productChart);
}
// A new FD priced from its product's own chart never carries a senior-citizen premium -- that
// concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a
// specific chart, price the account from that master's currently active version instead (the same
// one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows
// its premium from day one rather than only after its first renewal. RD is unaffected: this only
// runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate
// version exists for the product's FD type, in which case the product's own chart above stands.
if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName)
&& product instanceof FixedDepositProduct) {
final String fdType = ((FixedDepositProduct) product).getFdType();
if (fdType != null) {
final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService
.retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null);
if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) {
final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates);
final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs = DepositAccountDomainServiceJpa
.baseChartSlabsOf(activeBaseRates, product.currency().getCode());
accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
}
}
}
boolean withHoldTax = product.withHoldTax();
if (command.parameterExists(withHoldTaxParamName)) {
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail);
/**
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
*/
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
@@ -56,4 +64,7 @@ public interface DepositAccountDomainService {
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
@@ -26,6 +27,9 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository;
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
@@ -80,13 +90,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
private final CalendarInstanceRepository calendarInstanceRepository;
private final ExternalIdFactory externalIdFactory;
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Transactional
@Override
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
isAccountTransfer);
}
/**
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
*/
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
boolean isInterestTransfer = false;
boolean isWithdrawBalance = false;
final boolean backdatedTxnsAllowedTill = false;
@@ -100,7 +125,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
}
@Transactional
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer) {
boolean isRegularTransaction = false;
final boolean backdatedTxnsAllowedTill = false;
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
@@ -200,16 +233,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
// Captured before the withdrawal below empties the account, so the renewal history can
// record what this term actually matured at.
final BigDecimal maturedBalance = account.getAccountBalance();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId);
FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
// Renewal is an internal movement into the new deposit, not cash paid out -- post it
// against the transfer-suspense GL rather than crediting cash/vault.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// Reached when a user closes the account with a re-invest instruction, rather than the
// maturity job acting on a standing instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
@@ -260,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
if (onClosureType.isReinvestPrincipalAndInterest()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
// new term's slabs and its senior-citizen premium, so screen and account agree.
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
// Captured before renewInPlace overwrites the account's chart, so the closing term's full
// rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost
// -- renewInPlace mutates the same chart row every time, it does not create a new one.
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -276,18 +364,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction);
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (onClosureType.isReinvestPrincipalAndInterest()) {
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
}
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
// Reached from the maturity job processing the account's standing maturity instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -304,6 +400,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
// if(!processMaturityInstructionOnly)
@@ -429,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
}
/**
* Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium --
* against its {@link FixedDepositRenewalHistory} row.
* <p>
* Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the
* closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive.
*/
private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots, final String currencyCode) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) {
final InterestRateChartSlabFields fields = snapshot.getFields();
if (fields.annualInterestRate() == null || fields.fromPeriod() == null) {
continue;
}
this.fixedDepositRenewalChartSlabHistoryRepository
.save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(),
fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(),
fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate()));
}
}
/**
* Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private
* and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a
* new FD account is first priced from this master, instead of duplicating the logic.
*/
static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) {
// name and from_date are NOT NULL on the embeddable. A base version missing either would abort the
// whole renewal (or account creation) with a constraint violation, so treat it as "no identity to
// apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and
// the slabs -- the part that actually prices the deposit -- still get applied.
if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) {
return null;
}
final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount
return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(),
baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount);
}
/**
* Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen
* premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live.
* <p>
* The base master bands purely by tenure, so amount ranges are left unset -- which
* {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no
* from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure.
*/
static Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabsOf(final FdInterestRateEffectiveData baseRates,
final String currencyCode) {
if (baseRates == null || baseRates.getSlabs() == null) {
return Collections.emptyList();
}
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots = new ArrayList<>();
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) {
continue;
}
final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(),
SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null,
slab.getAnnualInterestRate(), currencyCode);
snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* The senior-citizen premium from the active base rate card for a given tenure.
* <p>
* The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years
* still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or
* months/years -- because only those are exact; a days-based card is therefore still never matched against a
* months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the
* wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the
* account's existing rate untouched rather than silently dropping the depositor's premium.
*/
private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure,
final Integer tenurePeriodTypeEnum) {
if (baseRates == null || baseRates.getSlabs() == null || tenure == null) {
return null;
}
final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum);
if (tenureInBaseUnit == null) {
return null;
}
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) {
continue;
}
final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum());
if (slabFrom == null || tenureInBaseUnit < slabFrom) {
continue;
}
if (slab.getToPeriod() != null) {
final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum());
if (slabTo == null || tenureInBaseUnit > slabTo) {
continue;
}
}
final BigDecimal rate = slab.getSeniorCitizenRate();
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
return rate;
}
}
return null;
}
/**
* {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so
* two periods written in different but related units can be compared without losing precision. Null for an unknown
* unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3).
*/
private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) {
if (value == null || periodTypeEnum == null) {
return null;
}
return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) {
case DAYS, MONTHS -> value;
case WEEKS -> value * 7;
case YEARS -> value * 12;
default -> null;
};
}
/**
* Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those
* two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never
* matched against a months-based tenure rather than guessing and landing the deposit in the wrong band.
*/
private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) {
if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) {
return false;
}
return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum);
}
private boolean isDayFamily(final Integer periodTypeEnum) {
final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum);
return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS;
}
/**
* Records a renewal that happened on the SAME account, where there is no second account row to describe the new term.
* <p>
* Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number,
* principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the
* account, where they no longer exist. Every row for one FD shares that FD's single account id and account number;
* they are told apart by renewal_no, which is what makes this a one-to-many history of one account.
*
* @param closingChartName
* the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if
* unavailable
* @param closingChartSlabs
* the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived
* against the closing term's row so its whole rate card remains queryable, not only the single effective
* rate {@link FixedDepositRenewalHistory} records.
* @param closingInterestRate
* the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it --
* needed only when this term has no existing row yet (see below), since the account itself no longer
* carries it once renewInPlace has run.
* @param closingTenure
* the closing term's own deposit period, captured the same way and needed for the same reason.
* @param closingTenureFrequencyEnum
* the closing term's own deposit period frequency, captured the same way and needed for the same reason.
*/
private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal,
final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount,
final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs, final BigDecimal closingInterestRate,
final Integer closingTenure, final Integer closingTenureFrequencyEnum) {
// The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old
// close-and-reopen model before renewing in place: its rows are keyed on the original account's
// number, so keying new rows on this account's number would split the history in two and re-create
// a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's
// own number when there is no chain, which is what the read side resolves to as well.
final String fdAccountNumber = account.fdAccountNumber();
final int newCycleNo = account.renewalCycleNo();
final Long createdById = user == null ? null : user.getId();
final List<FixedDepositRenewalHistory> existingRows = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber);
FixedDepositRenewalHistory closingRow = existingRows.stream()
.filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null);
// The term that just closed has no row when this FD renews for the first time since the history
// table existed. Written now, from the figures captured before the rollover, so the history
// always begins with the term the customer originally opened -- rate and tenure included, rather
// than left blank, since the caller captured them before renewInPlace overwrote the account with
// the new term's own values.
if (closingRow == null) {
closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(),
account.depositPeriodFrequency(), renewalType, renewedOn, createdById));
}
/**
* Records the renewal that started {@code newTerm}, and closes off the previous term's history row.
* <p>
* Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event
* itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not
* recoverable from the account rows alone. This writes it down explicitly.
* <p>
* The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only
* gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no
* original.
*/
private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm,
final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType,
final AppUser user) {
final String fdAccountNumber = previousTerm.fdAccountNumber();
final Long originalAccountId = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst()
.map(FixedDepositRenewalHistory::getOriginalSavingsAccountId)
.orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null);
FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository
.findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null);
// Backfill the opening term when the chain started before this table existed, so the history
// always begins with the account the customer actually opened.
if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) {
previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(),
previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(),
previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(),
previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(),
user == null ? null : user.getId()));
}
// What the closing term actually earned. maturityAmount is passed in rather than read off the
// account because the caller has already withdrawn the balance out of it by this point, which
// would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own
// term's outcome, so a reader never has to look at the next row to find out how one term did.
final BigDecimal previousPrincipal = previousTerm.getDepositAmount();
final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null
: maturityAmount.subtract(previousPrincipal);
if (previousRow != null) {
previousRow.recordOutcome(interestEarned, maturityAmount);
previousRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(previousRow);
}
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(),
previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount,
newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(),
newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId()));
}
@Transactional
@Override
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
@@ -474,6 +839,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
// accounted for or the postJournalEntries() call below posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.prematureClosure(user, command, changes);
@@ -484,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
* before.
*/
@Transactional
@Override
public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
final JsonCommand command, final Map<String, Object> changes) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
boolean isRegularTransaction = false;
boolean isAccountTransfer = false;
final boolean isPreMatureClosure = false;
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
final MathContext mc = MathContext.DECIMAL64;
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// account.isMatured() only reflects whether the overnight scheduler has already flipped this
// account's status -- not whether the customer actually showed up before or after the maturity
// date. A customer walking in ON the maturity date (or any day after) is not premature just
// because that job hasn't run yet today, so the real test is the date, not the status flag.
if (!account.isMatured()) {
if (!DateUtils.isBefore(closedDate, account.maturityDate())) {
// Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would:
// flip to Matured and post final interest at the full contracted rate, no penalty.
account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
} else {
// The three closing closure types re-check this themselves inside prematureClosure()
// below, but the two reinvest branches never call it (they keep the account open on the
// same term), so it is enforced once, up front, for every genuinely-premature Payment --
// a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via
// this action either.
if (account.isLockedOnDate(closedDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed",
"This account is still within its lock-in period and cannot be processed before it elapses.", closedDate);
}
// Same penalty-aware interest calculation prematureClosure() already relies on, so a
// genuinely early Payment settles interest exactly as a Premature Close would.
account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
} else {
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
// Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads
// getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set
// before branching. renewInPlace() clears it again for the branches that keep the account open.
account.setClosedOnDate(closedDate);
// Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now
// correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way.
final boolean alreadyMatured = account.isMatured();
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount;
if (onClosureType.isPartialReinvest()) {
renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName);
if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0
|| renewedAmount.compareTo(maturedBalance) >= 0) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else {
renewedAmount = maturedBalance;
}
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
}
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
return savingsTransactionId;
}
@Transactional
@Override
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
@@ -276,7 +277,9 @@ public class FixedDepositAccount extends SavingsAccount {
public LocalDate calculateMaturityDate() {
final LocalDate startDate = accountSubmittedOrActivationDate();
// The CURRENT term's start, not the account's activation date -- on a renewed FD these differ,
// and measuring the term from activation would put maturity in the past.
final LocalDate startDate = depositStartDate();
LocalDate maturityDate = null;
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
@@ -312,13 +315,19 @@ public class FixedDepositAccount extends SavingsAccount {
.fromInt(this.interestCalculationDaysInYearType);
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
// Scoped to the current term, so a renewed FD's projection covers only the term now running --
// matching what postInterest() will actually post -- rather than replaying the whole account.
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
// On a first term the opening deposit falls inside the first posting period, so the period can
// start from zero and pick the balance up from that transaction. On a renewed term that deposit
// is BEFORE the period range and would be missed, leaving the term to earn interest on nothing,
// so the term's own principal seeds the opening balance instead.
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
@@ -686,6 +695,60 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.maturityAmount();
}
/**
* The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to
* this account's own number when it has never been renewed, so callers always get a usable FD number.
*/
public String fdAccountNumber() {
final String carried = this.accountTermAndPreClosure.getFdAccountNumber();
return carried != null ? carried : getAccountNumber();
}
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
public Integer renewalCycleNo() {
return this.accountTermAndPreClosure.getRenewalCycleNo();
}
public Integer depositPeriod() {
return this.accountTermAndPreClosure.depositPeriod();
}
public Integer depositPeriodFrequency() {
return this.accountTermAndPreClosure.depositPeriodFrequency();
}
public Long reinvestedFromSavingsId() {
return this.accountTermAndPreClosure.getReinvestedFromSavingsId();
}
/**
* The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's
* chart identity can be archived rather than lost.
*/
public String currentChartName() {
return this.chart == null ? null : this.chart.chartFields().getName();
}
/**
* The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached
* from the chart's own persistence lifecycle.
* <p>
* Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full
* rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal
* history records -- rather than being silently deleted when the new term's slabs replace it.
*/
public List<DepositAccountInterestRateChart.SlabSnapshot> currentChartSlabFields() {
return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots();
}
/**
* This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when
* the product is not a Fixed Deposit product or has no type set.
*/
public String fdType() {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
private Money totalInterestPosted() {
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
@@ -715,11 +778,26 @@ public class FixedDepositAccount extends SavingsAccount {
return actualChanges;
}
/**
* The start of the term currently running, which is what the deposit's tenure and rate must be measured from.
* <p>
* For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances
* startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS
* term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the
* renewal rather than from the original activation.
*/
private LocalDate depositStartDate() {
// TODO: Support to add deposit start date which can be a date after
// account activation date.
final LocalDate depositStartDate = accountSubmittedOrActivationDate();
return depositStartDate;
final LocalDate currentTermStartDate = getStartInterestCalculationDate();
return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate();
}
/**
* True once this FD has been renewed at least once, i.e. the term now running started later than the account did.
* Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term.
*/
private boolean isRenewedTerm() {
final LocalDate termStart = getStartInterestCalculationDate();
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
private LocalDate interestCalculatedUpto() {
@@ -814,28 +892,142 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
}
/**
* Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it
* renews. No new account is opened and no money moves: the balance simply carries into the next term.
* <p>
* What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines
* interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the
* posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes
* {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it
* stands today, so a renewal is priced on current terms rather than the ones frozen at account opening.
* <p>
* The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal
* history so each term's own figures remain reportable.
*
* @param renewedAmount
* what carries into the new term: the full maturity amount, or the principal only
* @param renewedOn
* the renewal date, which becomes the new term's start
* @param currentSeniorCitizenRate
* the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to
* keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the
* renewal never turns that flag on or off.
* @param baseChartFields
* identity of the CBS base rate version being applied, or null to keep the existing chart's
* @param baseChartSlabs
* the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the
* rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls
* back to the product's own chart, so a product with no base rate version still renews on something.
*/
public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate,
final InterestRateChartFields baseChartFields, final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs) {
final SavingsProduct product = this.product;
// Confines interest posting to the new term. Set before recomputing maturity, since maturity is
// measured from the current term's start.
setStartInterestCalculationDate(renewedOn);
this.accountTermAndPreClosure.updateDepositAmount(renewedAmount);
this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Pin the chain number now that this account has renewed at least once, so a non-zero cycle number
// is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number
// if there is one and to this account's own otherwise, which is the same value every reader derives.
this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber());
// Re-price off the CURRENT product, exactly as opening a fresh deposit today would.
this.nominalAnnualInterestRate = product.nominalAnnualInterestRate();
this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue();
this.interestPostingPeriodType = product.interestPostingPeriodType().getValue();
this.interestCalculationType = product.interestCalculationType().getValue();
this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue();
this.withHoldTax = product.withHoldTax();
// Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy
// close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would
// carry the lock-in window computed at the original activation -- long expired -- so a product with
// a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only.
final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
this.lockinPeriodFrequency = product.lockinPeriodFrequency();
this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null
: renewedLockinPeriodFrequencyType.getValue();
this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null
: calculateDateAccountIsLockedUntil(renewedOn);
// The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on
// the Base Interest Rates screen, per FD type, with its own active version. That master is what
// the bank publishes and what the account-opening wizard already shows, so pricing a renewal off
// anything else would put the deposit on rates nobody chose.
if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) {
this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
} else if (this.chart != null) {
// No active base version for this FD type: fall back to the product's own chart so the
// renewal still gets current product rates rather than the ones frozen at opening.
// activeChart, not applicableChart -- the latter treats a chart with no end date as valid only
// up to today's business date, so a renewal dated in the future would find nothing.
final InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(renewedOn)
: null;
if (productChart != null) {
this.chart.refreshFrom(productChart);
}
}
// The senior-citizen premium is stored per account and takes priority over the chart in
// getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a
// senior-citizen deposit at the rate captured when the account was opened. Only overwritten when
// the caller found a currently published rate -- otherwise the existing one stands, so a missing
// rate version can never silently drop the depositor's premium.
if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) {
setSeniorCitizenInterestRate(currentSeniorCitizenRate);
}
// The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before
// this ran, so the new term has to put it back.
this.status = SavingsAccountStatusType.ACTIVE.getValue();
setClosedOnDate(null);
}
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
final SavingsProduct product = this.product;
final InterestRateChart productChart = product.applicableChart(getClosedOnDate());
// Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's
// business date, and the most recently effective chart wins when more than one covers the date.
// Falls back to applicableChart so a product whose charts have no usable from-date still resolves
// as it did before.
InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(getClosedOnDate())
: null;
if (productChart == null) {
productChart = product.applicableChart(getClosedOnDate());
}
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
final AccountType accountType = AccountType.fromInt(this.accountType);
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
// Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's
// frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a
// renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting
// was in force when the ORIGINAL account was opened, silently ignoring every product edit made
// since (e.g. Daily->Quarterly compounding, or a lock-in added later).
final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType();
final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType();
final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType();
final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType();
final BigDecimal minRequiredOpeningBalance = null;
final BigDecimal interestRate = BigDecimal.ZERO;
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
// original account was opened) whenever a matching slab exists.
final BigDecimal interestRate = product.nominalAnnualInterestRate();
final Set<SavingsAccountCharge> savingsAccountCharges = null;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
final Integer lockinPeriodFrequency = product.lockinPeriodFrequency();
final boolean withdrawalFeeApplicableForTransfer = false;
final String accountNumber = null;
final boolean withHoldTax = this.withHoldTax;
final boolean withHoldTax = product.withHoldTax();
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
@@ -845,6 +1037,30 @@ public class FixedDepositAccount extends SavingsAccount {
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
newChart.updateDepositAccountReference(reInvestedAccount);
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
// reinvested FD silently drops back to the standard rate on the new term.
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
// each other instead of the link disappearing the moment the old one closes.
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
// Carry the ORIGINAL account's number forward across every renewal, so the customer keeps
// seeing one recognisable FD number even though each term is its own account/ledger row.
final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null
? this.accountTermAndPreClosure.getFdAccountNumber()
: this.getAccountNumber();
newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber);
// Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048".
newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Without this the renewed deposit has no maturity instruction, so it would mature once more and
// then sit idle rather than rolling over again as the customer asked. Done here rather than in
// the shared copy(), which Recurring Deposit renewal also uses.
newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure);
return reInvestedAccount;
}
@@ -886,7 +1102,9 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public void loadLazyCollections() {
super.loadLazyCollections();
this.chart.getId();
if (this.chart != null) {
this.chart.getId();
}
}
public BigDecimal getDepositAmount() {
@@ -902,6 +1120,52 @@ public class FixedDepositAccount extends SavingsAccount {
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
}
/**
* Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the
* Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going
* through {@link #prematureClosure}, since those branches keep the account open on the same term instead of
* closing it.
*/
public boolean isLockedOnDate(final LocalDate transactionDate) {
return isAccountLocked(transactionDate);
}
/**
* The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment
* that renews the deposit instead of closing it and so never reaches either of those methods.
* <p>
* Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three
* matter more here than they do on a closure, because a renewal turns this date into the new term's interest
* pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting
* periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent.
*/
public void validateRenewalDate(final LocalDate renewedOn) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction);
if (DateUtils.isAfterBusinessDate(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("cannot.be.a.future.date");
}
if (DateUtils.isBefore(renewedOn, getActivationDate())) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.activation.date");
}
final List<SavingsAccountTransaction> savingsAccountTransactions = retrieveListOfTransactions();
if (!savingsAccountTransactions.isEmpty()) {
final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1);
if (lastTransaction.isAfter(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.last.transaction.date");
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
public void setClosedOnDate(final LocalDate closedOnDate) {
this.closedOnDate = closedOnDate;
}
@@ -0,0 +1,99 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's
* {@link FixedDepositRenewalHistory} row.
* <p>
* {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in
* place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not
* the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during
* term 2", not just "what rate applied".
*/
@Entity
@Table(name = "m_fd_renewal_chart_slab_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalChartSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "fd_renewal_history_id", nullable = false)
private Long fdRenewalHistoryId;
@Column(name = "chart_name", length = 100)
private String chartName;
@Column(name = "description", length = 200)
private String description;
@Column(name = "period_type_enum")
private Integer periodTypeEnum;
@Column(name = "from_period")
private Integer fromPeriod;
@Column(name = "to_period")
private Integer toPeriod;
@Column(name = "amount_range_from", scale = 6, precision = 19)
private BigDecimal amountRangeFrom;
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;
@Column(name = "annual_interest_rate", scale = 6, precision = 19)
private BigDecimal annualInterestRate;
@Column(name = "currency_code", length = 3)
private String currencyCode;
/**
* The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the
* CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was
* priced off the product's own chart (which has no senior-citizen concept) or predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName,
final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod,
final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate,
final String currencyCode, final BigDecimal seniorCitizenRate) {
final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory();
slab.fdRenewalHistoryId = fdRenewalHistoryId;
slab.chartName = chartName;
slab.description = description;
slab.periodTypeEnum = periodTypeEnum;
slab.fromPeriod = fromPeriod;
slab.toPeriod = toPeriod;
slab.amountRangeFrom = amountRangeFrom;
slab.amountRangeTo = amountRangeTo;
slab.annualInterestRate = annualInterestRate;
slab.currencyCode = currencyCode;
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
}
@@ -0,0 +1,21 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository<FixedDepositRenewalChartSlabHistory, Long> {
/** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */
List<FixedDepositRenewalChartSlabHistory> findByFdRenewalHistoryId(Long fdRenewalHistoryId);
}
@@ -0,0 +1,158 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One row per term of one logical Fixed Deposit, recording the renewal event that started it.
* <p>
* Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable
* ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is
* what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is
* what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried
* forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a
* user, and by whom.
*/
@Entity
@Table(name = "m_fd_renewal_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalHistory {
/** Renewal ran automatically when the maturity job processed the account's maturity instruction. */
public static final String RENEWAL_TYPE_AUTO = "AUTO";
/** A user closed the account with a re-invest instruction. */
public static final String RENEWAL_TYPE_MANUAL = "MANUAL";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_MATURED = "MATURED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The stable customer-facing FD number, shared by every term in the chain. */
@Column(name = "fd_account_number", length = 20, nullable = false)
private String fdAccountNumber;
/** The account the customer originally opened, i.e. the anchor of the chain. */
@Column(name = "original_savings_account_id", nullable = false)
private Long originalSavingsAccountId;
/** The deposit instance this term runs on. */
@Column(name = "savings_account_id", nullable = false)
private Long savingsAccountId;
/** The term this one was renewed from; null on the chain's first row. */
@Column(name = "previous_savings_account_id")
private Long previousSavingsAccountId;
@Column(name = "previous_renewal_id")
private Long previousRenewalId;
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
@Column(name = "renewal_no", nullable = false)
private Integer renewalNo;
@Column(name = "previous_principal", scale = 6, precision = 19)
private BigDecimal previousPrincipal;
@Column(name = "interest_earned", scale = 6, precision = 19)
private BigDecimal interestEarned;
@Column(name = "maturity_amount", scale = 6, precision = 19)
private BigDecimal maturityAmount;
/** What actually carried into this term: the full maturity amount, or principal only. */
@Column(name = "renewed_amount", scale = 6, precision = 19)
private BigDecimal renewedAmount;
@Column(name = "interest_rate", scale = 6, precision = 19)
private BigDecimal interestRate;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "tenure")
private Integer tenure;
@Column(name = "tenure_frequency_enum")
private Integer tenureFrequencyEnum;
@Column(name = "renewal_type", length = 10)
private String renewalType;
@Column(name = "renewal_status", length = 20)
private String renewalStatus;
@Column(name = "renewed_on")
private LocalDate renewedOn;
@Column(name = "created_by_id")
private Long createdById;
public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId,
final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo,
final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount,
final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate,
final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn,
final Long createdById) {
final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory();
history.fdAccountNumber = fdAccountNumber;
history.originalSavingsAccountId = originalSavingsAccountId;
history.savingsAccountId = savingsAccountId;
history.previousSavingsAccountId = previousSavingsAccountId;
history.previousRenewalId = previousRenewalId;
history.renewalNo = renewalNo;
history.previousPrincipal = previousPrincipal;
history.interestEarned = interestEarned;
history.maturityAmount = maturityAmount;
history.renewedAmount = renewedAmount;
history.interestRate = interestRate;
history.startDate = startDate;
history.maturityDate = maturityDate;
history.tenure = tenure;
history.tenureFrequencyEnum = tenureFrequencyEnum;
history.renewalType = renewalType;
history.renewalStatus = STATUS_ACTIVE;
history.renewedOn = renewedOn;
history.createdById = createdById;
return history;
}
/** Called on the previous term's row once its successor takes over. */
public void markMatured() {
this.renewalStatus = STATUS_MATURED;
}
/**
* Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's
* own row so each term reports its own outcome rather than that of its predecessor.
*/
public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) {
this.interestEarned = interestEarned;
this.maturityAmount = maturityAmount;
}
}
@@ -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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalHistoryRepository extends JpaRepository<FixedDepositRenewalHistory, Long> {
/** One FD's whole history, oldest term first. */
List<FixedDepositRenewalHistory> findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber);
/**
* An account's terms, newest first.
* <p>
* Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at
* that one account, so callers wanting the term currently running take the first element. Empty for an account that
* pre-dates this table or was never renewed.
*/
List<FixedDepositRenewalHistory> findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId);
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@Autowired
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
}
}
@@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
.append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ")
.append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
@@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate");
// currency Slabs
final String currencyCode = rs.getString("currencyCode");
@@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
currencyNameCode);
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, currency);
amountRangeTo, annualInterestRate, currency, seniorCitizenRate);
}
}
@@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService {
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
/**
* As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries
* PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it.
*/
DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId);
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
}
}
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
}
}
return account;
@@ -329,6 +332,158 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}
}
/**
* Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the
* account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest
* Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW
* account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row
* points at it.
*/
private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result,
final Long accountId) {
final String fromSql = "select src.id as id, src.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t "
+ "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?";
final String intoSql = "select dest.id as id, dest.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id "
+ "where t.reinvested_from_savings_id = ?";
Long fromId = null;
String fromNo = null;
final java.util.List<java.util.Map<String, Object>> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId);
if (!fromRows.isEmpty()) {
fromId = ((Number) fromRows.get(0).get("id")).longValue();
fromNo = (String) fromRows.get(0).get("accountNo");
}
Long intoId = null;
String intoNo = null;
final java.util.List<java.util.Map<String, Object>> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId);
if (!intoRows.isEmpty()) {
intoId = ((Number) intoRows.get(0).get("id")).longValue();
intoNo = (String) intoRows.get(0).get("accountNo");
}
if (fromId != null || intoId != null) {
result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo);
}
// The number to actually show as "the FD account number" -- the original account's account_no
// if this account has been through a renewal, otherwise this account's own account_no.
final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure "
+ "where savings_account_id = ?";
final java.util.List<java.util.Map<String, Object>> termRows = this.jdbcTemplate.queryForList(termSql, accountId);
final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number");
final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no");
final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo();
result.setFdAccountNumber(rootFdAccountNumber);
result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue());
result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber));
}
/**
* Every term of one logical FD, oldest first.
* <p>
* Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it
* matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by
* joining account rows, which cannot express any of that. The account row is joined in for its number and live
* status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as
* simply a non-empty list.
*/
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistory(final String fdAccountNumber) {
// Every row states its own term's figures, so this is a straight read: interest_earned and
// maturity_amount are filled in when that term closes, and stay null on the term still running,
// which the caller renders as its projected maturity instead.
final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, "
+ "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, "
+ "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, "
+ "h.tenure_frequency_enum as tenureFrequencyEnum, "
+ "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, "
+ "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, "
+ "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h "
+ "join m_savings_account sa on sa.id = h.savings_account_id "
+ "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where h.fd_account_number = ? " + "order by h.renewal_no asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber);
// Any row here is a renewal event that was actually recorded, so even one is worth returning --
// discarding a lone row used to hide a genuine renewal (and its interest figure) behind the
// account-derived fallback, which then returned nothing at all for it.
if (!rows.isEmpty()) {
attachArchivedChartSlabs(rows);
return rows;
}
// Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to
// reconstructing what the account rows alone can tell us. The per-term interest/renewal-type
// columns are simply absent for those, which the UI renders as blanks rather than wrong values.
return retrieveRenewalHistoryFromAccounts(fdAccountNumber);
}
/**
* Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row.
* <p>
* A term's chart is only archived when it closes via an in-place renewal (see
* {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before
* that archiving existed simply get an empty list here -- callers should fall back to the account's live
* accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since
* this runs once per FD account view.
*/
private void attachArchivedChartSlabs(final java.util.List<java.util.Map<String, Object>> rows) {
final java.util.List<Long> historyIds = new java.util.ArrayList<>();
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
if (id != null) {
historyIds.add(((Number) id).longValue());
}
}
if (historyIds.isEmpty()) {
return;
}
final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?"));
final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, "
+ "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, "
+ "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate "
+ "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") "
+ "order by from_period asc";
final java.util.List<java.util.Map<String, Object>> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray());
final java.util.Map<Long, java.util.List<java.util.Map<String, Object>>> slabsByHistoryId = new java.util.HashMap<>();
for (final java.util.Map<String, Object> slabRow : slabRows) {
final Long historyId = ((Number) slabRow.remove("historyId")).longValue();
slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow);
}
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
final Long historyId = id == null ? null : ((Number) id).longValue();
row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList()));
}
}
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) {
// Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has
// to know which path produced the rows. What the account rows genuinely cannot tell us -- each
// term's interest earned, and whether the rollover was automatic or done by staff -- is returned as
// an explicit null rather than omitted, so those render as blanks instead of the previous behaviour
// where a missing renewalStatus made every term, including the running one, look matured.
final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, "
+ "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, "
+ "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, "
+ "t.maturity_date as maturityDate, t.deposit_period as tenure, "
+ "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, "
+ "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, "
+ "sa.status_enum as statusEnum, null as interestEarned, "
+ "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, "
+ "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount "
+ "from m_savings_account sa "
+ "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber,
fdAccountNumber);
// A single row here means this FD simply never renewed -- one account, one term -- so there is no
// history to show. Unlike the primary path, where even one recorded row is a real renewal event.
return rows.size() > 1 ? rows : java.util.Collections.emptyList();
}
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
@@ -349,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}, accountId);
}
/**
* Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the
* Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it
* needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for
* it and would fall through to paying the whole balance out in cash.
*/
@Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = false;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
/**
* The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that
* implements it.
*/
@Override
public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = true;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId,
final boolean includePartialReinvest) {
try {
this.context.authenticatedUser();
@@ -367,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(DepositAccountOnClosureType.values());
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -912,6 +913,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Override
public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
this.depositAccountTransactionDataValidator.validatePayment(command);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
// Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close
// (Active only) -- but it must still be one of those two. Without this, a closed or
// pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to
// ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it.
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
if (!account.isActive() && !account.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured",
"Payment can only be processed on an active or matured fixed deposit account.", savingsId);
}
checkClientOrGroupActive(account);
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
final Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale());
final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null,
isInternalTransferLeg);
}
final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.savingNote(account, noteText);
changes.put("note", noteText);
this.noteRepository.save(note);
}
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes)//
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1404,16 +1466,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
Money amountForDeposit = account.activateWithBalance();
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
// Matching internal-transfer leg for the withdrawal the closure just made on the
// matured account: the funds never touch cash, so this posts against the
// transfer-suspense GL instead of debiting cash/vault.
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
amountForDeposit.getAmount(), null);
amountForDeposit.getAmount(), null, isInternalTransferLeg);
}
}
} else if (depositAccountType.isRecurringDeposit()) {
@@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData {
minimumActivePeriodFrequencyTypeOption, currenMarketPrice);
}
/**
* Whether the member's money has actually been taken and the shares issued — either by applying a cash collection
* or by debiting their savings.
*
* Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and
* the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved,
* which is what the Transactions, Passbook and Charges views actually describe.
*/
private Boolean collectionApplied;
public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) {
this.currentMarketPrice = currentMarketPrice;
}
public void setCollectionApplied(final Boolean collectionApplied) {
this.collectionApplied = collectionApplied;
}
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
this.linkedLoanId = linkedLoanId;
this.linkedLoanAccountNo = linkedLoanAccountNo;
@@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom<Long> {
this.amountOrPercentage = chargeAmount;
if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) {
this.percentage = null;
this.amount = BigDecimal.ZERO;
// A flat charge is owed in full the moment it is attached: its amount IS the
// configured fee, and all of it is outstanding until something pays it. Leaving
// both at zero (as this once did) made the entry fee invisible to every consumer
// that reads amount/amount_outstanding_derived — the share-collection settlement
// then asked the member for the share value alone and the fee went uncollected.
this.amount = chargeAmount;
this.amountPercentageAppliedTo = null;
this.amountPaid = null;
this.amountOutstanding = BigDecimal.ZERO;
this.amountOutstanding = calculateOutstanding();
this.amountWaived = null;
this.amountWrittenOff = null;
} else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) {
@@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
* forfeited — the hold on the member's savings account is released and the amount is swept back
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
* the actual GL/balance mechanics.
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with
* no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is
* released and the amount is swept back into the bank's profit. See
* {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics.
*/
@Slf4j
@RequiredArgsConstructor
@@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List<Throwable> exceptions = new ArrayList<>();
// 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure
// "Dividend Payable" GL liability and renamed the initial status accordingly (see
// makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on
// 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently
// selected zero rows and nothing was ever forfeited.
final List<Long> lockIds = this.jdbcTemplate.queryForList(
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
"SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class);
for (final Long lockId : lockIds) {
try {
@@ -654,18 +654,25 @@ public class ShareAccountDataSerializer {
}
private void handlechargesOnActivation(final ShareAccount account) {
Set<ShareAccountCharge> charges = account.getCharges();
for (ShareAccountCharge charge : charges) {
if (charge.isActive() && charge.isShareAccountActivation()) {
charge.markAsFullyPaid();
}
}
// The entry fee is deliberately NOT marked paid here. Activating the account decides the
// member may hold shares; it does not collect anything, and nobody has yet said where the
// money comes from — a cash receipt at the counter, or a debit from the member's savings.
//
// Marking it paid at activation left amount_paid_derived at the full fee and
// amount_outstanding_derived at zero while nothing had actually been received, so the
// settlement step read the fee as already settled and asked the member for the share value
// alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
//
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource
// (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set
// amount_paid_derived and amount_outstanding_derived alongside the flag.
//
// The charge transaction is still dated to the activation date — that records when the fee
// became due, which is a separate thing from when it was paid.
Set<ShareAccountTransaction> transactions = account.getChargeTransactions();
for (ShareAccountTransaction transaction : transactions) {
if (transaction.isChargeTransaction()) {
transaction.updateTransactionDate(account.getActivatedDate());
transaction.updateAmountPaid(transaction.amount());
}
}
}
@@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false);
final BigDecimal currentMarketPrice = deriveMarketPrice(productData);
data.setCurrentMarketPrice(currentMarketPrice);
// Read separately rather than added to the row mapper, which is shared with the list and
// template queries that have no use for it.
data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account "
+ " WHERE id = ?", Boolean.class, id));
if (!includeTemplate) {
Collection<ShareAccountDividendData> dividends = this.retrieveAssociatedDividends(id);
data.setDividends(dividends);
@@ -40,8 +40,13 @@ import org.springframework.transaction.annotation.Transactional;
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
private final SavingsAccountAssembler savingsAccountAssembler;
@@ -62,12 +67,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
}
/**
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
* account up front -- it stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
* dividend past its 3-year deadline has no savings hold to release and no account balance to
* deduct -- the liability is simply written off directly against the bank's profit.
* Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it
* stays purely a "Dividend Payable" GL liability (see
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually
* claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no
* savings hold to release and no account balance to deduct -- the liability is simply written off directly against
* the bank's profit.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -84,22 +89,55 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
postForfeitureJournalEntry(lockId, currencyCode, amount);
this.jdbcTemplate.update(
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
}
/**
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
* Reads currency straight off the lock row (set from the share account at creation time) --
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
* both GL accounts yet, exactly like the declaration-time Reserve entry.
* The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is
* broken — a missing label must not stop the forfeiture from posting.
*/
private String financialYearOfLock(final Long lockId) {
final java.util.List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT d.dividend_period_end_date
FROM m_share_dividend_lock l
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
WHERE l.id = ?
""", lockId);
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
return "unknown";
}
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Writes off a dividend nobody came to claim.
*
* <pre>
* Dr Dividend Payable — the obligation ends; it was never going to be paid
* Cr Dividend Forfeited Income
* </pre>
*
* Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and
* loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every
* other income account, which is the same equity account the dividend was declared out of — so the money completes
* its round trip, by way of the P&L.
*
* Currency comes straight off the lock row (set from the share account when the lock was created), so this never
* depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
* declaration-time entries.
*/
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
final Long payableDividendsAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank
// Profit along with every other income account — so the money still ends up back in the
// equity account the dividend was declared out of, but by way of the P&L, where a forfeited
// dividend is visible as that year's income rather than appearing in equity with no trace.
final Long forfeitedIncomeAccountId = this.jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
@@ -112,7 +150,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVFORFEIT" + lockId;
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
// Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in —
// a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each
// year's declaration went unclaimed and came back to profit.
final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -120,10 +161,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
// Debit first, then credit, so the entry ids follow the direction the money travelled.
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
}
@@ -125,11 +125,10 @@ public class ShareAccountsConfiguration {
ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository,
AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository,
org.springframework.jdbc.core.JdbcTemplate jdbcTemplate,
BusinessEventNotifierService businessEventNotifierService) {
return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository,
accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository,
jdbcTemplate, businessEventNotifierService);
businessEventNotifierService);
}
@Bean
@@ -0,0 +1,579 @@
/**
* 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.shareproducts.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Declares one dividend across every share product at once.
*
* A society's board resolves a single rate for a financial year — it does not resolve one for each share product — but
* Fineract only declares dividends one product at a time. Doing that by hand means repeating the same decision per
* product and hoping the rate and period match, when a mismatch would quietly pay two classes of member at different
* rates out of one pool.
*
* Each product still gets its own declaration row, because share capital lives in a separate GL per product
* (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one
* rate, one period, applied to every product that has members holding shares in it.
*
* The declarations are created through the existing per-product command, so the whole downstream chain — statutory
* appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it
* does for a single-product declaration.
*/
@Path("/v1/shareproducts/dividends")
@Component
@Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.")
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AllProductsDividendApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private static final int DIVIDEND_STATUS_INITIATED = 100;
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
/** Every member receives the declared rate on their own capital — what an AGM resolution promises. */
private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL";
/** The pool is a share of distributable profit, split across members afterwards. */
private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── GET /v1/shareproducts/dividends ── Every declaration, across products ──
@GET
public Response list() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT d.id, d.product_id, p.name AS product_name, d.amount,
d.dividend_period_start_date, d.dividend_period_end_date, d.status, d.created_date,
(SELECT COUNT(*) FROM m_share_account_dividend_details x
WHERE x.dividend_pay_out_id = d.id AND x.amount > 0) AS member_count
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
ORDER BY d.dividend_period_end_date DESC, d.product_id
""");
final List<Map<String, Object>> entries = new ArrayList<>();
for (final Map<String, Object> row : rows) {
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("dividendId", row.get("id"));
entry.put("productId", row.get("product_id"));
entry.put("productName", row.get("product_name"));
entry.put("amount", row.get("amount"));
entry.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
entry.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
entry.put("financialYear", financialYearOf(String.valueOf(row.get("dividend_period_end_date"))));
entry.put("approved", ((Number) row.get("status")).intValue() != DIVIDEND_STATUS_INITIATED);
entry.put("memberCount", row.get("member_count"));
entry.put("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date")));
entries.add(entry);
}
return Response.ok(gson.toJson(entries)).build();
}
// ── GET /v1/shareproducts/dividends/preview ── What a rate costs, per product ──
/**
* What declaring this rate would cost, product by product, before anything is created.
*
* A rate is a promise about each member's own paid-up capital, so the amount it costs depends on how much capital
* each product's members held and for how long — which differs per product. Showing that split before the officer
* commits is what makes a single cross-product decision safe to take.
*
* A product with no eligible members is reported with a zero and a reason rather than omitted, so it is clear it
* was considered and skipped rather than overlooked.
*/
@GET
@Path("/preview")
public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("ratePercentage") final BigDecimal ratePercentage,
@QueryParam("dividendBasis") final String dividendBasis) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final LocalDate from = parseDate(fromDate);
final LocalDate to = parseDate(toDate);
if (to.isBefore(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
final String basis = normaliseBasis(dividendBasis);
final List<ProductCapital> capitals = capitalPerProduct(from, to);
final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
final ProfitWaterfall waterfall = profitWaterfall(from, to);
// Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in
// proportion to the capital each carries — a share of profit is a society-wide figure, so it
// cannot be computed per product the way a rate on capital can.
final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null
? percentageOf(waterfall.distributableProfit, ratePercentage)
: null;
final List<Map<String, Object>> products = new ArrayList<>();
BigDecimal grandTotal = BigDecimal.ZERO;
for (final ProductCapital capital : capitals) {
final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital,
profitPool);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("productId", capital.productId);
entry.put("productName", capital.productName);
entry.put("weightedCapital", capital.weightedCapital);
entry.put("amount", amount);
entry.put("eligible", capital.weightedCapital.signum() > 0);
entry.put("reason", capital.weightedCapital.signum() > 0 ? null
: "No member has held shares in this product long enough to qualify for this period.");
entry.put("alreadyDeclared", hasDeclarationFor(capital.productId, from, to));
products.add(entry);
grandTotal = grandTotal.add(amount);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("fromDate", fromDate);
out.put("toDate", toDate);
out.put("financialYear", financialYearOf(toDate));
out.put("dividendBasis", basis);
out.put("ratePercentage", ratePercentage);
out.put("totalWeightedCapital", totalWeightedCapital);
out.put("profitGlCode", PROFIT_GL_CODE);
// The whole waterfall, so the screen can show where the declarable figure came from rather
// than presenting it as a number the officer has to take on trust.
out.putAll(waterfall.asMap());
// What is left in the profit account after this declaration — the amount that will be swept
// to Bank Profit on approval, and which no later year's dividend can draw on.
out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO));
out.put("exceedsDistributableProfit", grandTotal.compareTo(waterfall.distributableProfit) > 0);
out.put("totalAmount", grandTotal);
out.put("products", products);
return Response.ok(gson.toJson(out)).build();
}
// ── POST /v1/shareproducts/dividends ── Declare across every product ──
/**
* Creates one declaration per eligible product, all for the same period and rate.
*
* Products with no eligible members, and products that already carry a declaration overlapping this period, are
* skipped and reported rather than failing the whole request — one product being ineligible is not a reason to
* leave the rest undeclared.
*/
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public Response declare(final String body) {
this.context.authenticatedUser();
final JsonObject json = parseBody(body);
final String fromDate = getString(json, "dividendPeriodStartDate");
final String toDate = getString(json, "dividendPeriodEndDate");
if (fromDate == null || toDate == null) {
throw apiError(400, "dividendPeriodStartDate and dividendPeriodEndDate (yyyy-MM-dd) are both required");
}
final LocalDate from = parseDate(fromDate);
final LocalDate to = parseDate(toDate);
final String basis = normaliseBasis(getString(json, "dividendBasis"));
final String rateText = getString(json, "ratePercentage");
if (rateText == null) {
throw apiError(400, "ratePercentage is required");
}
final BigDecimal rate = new BigDecimal(rateText);
if (rate.signum() <= 0) {
throw apiError(400, "ratePercentage must be greater than zero");
}
final List<ProductCapital> capitals = capitalPerProduct(from, to);
final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis)
? percentageOf(profitWaterfall(from, to).distributableProfit, rate)
: null;
final List<Map<String, Object>> created = new ArrayList<>();
final List<Map<String, Object>> skipped = new ArrayList<>();
for (final ProductCapital capital : capitals) {
final Long productId = capital.productId;
final String productName = capital.productName;
if (hasDeclarationFor(productId, from, to)) {
skipped.add(skipRow(productId, productName, "A dividend is already declared for this product over this period."));
continue;
}
if (capital.weightedCapital.signum() <= 0) {
skipped.add(skipRow(productId, productName,
"No member has held shares in this product long enough to qualify for this period."));
continue;
}
final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool);
if (amount.signum() <= 0) {
skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product."));
continue;
}
// Routed through the existing per-product command so the whole downstream chain —
// appropriations, rounding, residue sweep, ceiling check — runs exactly as it does for a
// single-product declaration, rather than being reimplemented here.
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("dividendPeriodStartDate", fromDate);
payload.addProperty("dividendPeriodEndDate", toDate);
payload.addProperty("dividendAmount", amount);
try {
final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("productId", productId);
row.put("productName", productName);
row.put("amount", amount);
row.put("dividendId", result.getSubResourceId());
created.add(row);
} catch (final RuntimeException e) {
// Most often the distributable-profit ceiling: an earlier product in this same run has
// already consumed the pool. Reported per product so the officer can see which ones
// went through and why the rest did not, instead of losing all of them to one failure.
skipped.add(skipRow(productId, productName,
e.getMessage() == null ? "Could not declare for this product." : e.getMessage()));
}
}
if (created.isEmpty()) {
throw apiError(400, "No dividend could be declared. " + (skipped.isEmpty() ? "There are no share products."
: skipped.get(0).get("reason").toString()));
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYearOf(toDate));
out.put("declarationsCreated", created.size());
out.put("created", created);
out.put("skipped", skipped);
return Response.ok(gson.toJson(out)).build();
}
// ── Helpers ────────────────────────────────────────────────────────────
/** One product's weighted paid-up capital for the period. */
private static final class ProductCapital {
private final Long productId;
private final String productName;
private final BigDecimal weightedCapital;
private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) {
this.productId = productId;
this.productName = productName;
this.weightedCapital = weightedCapital;
}
}
/** Weighted paid-up capital for every share product, in one pass. */
private List<ProductCapital> capitalPerProduct(final LocalDate from, final LocalDate to) {
final List<ProductCapital> capitals = new ArrayList<>();
for (final Map<String, Object> product : jdbcTemplate
.queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) {
final Long productId = ((Number) product.get("id")).longValue();
final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0
: new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue();
final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod);
capitals.add(new ProductCapital(productId, (String) product.get("name"),
capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP)));
}
return capitals;
}
/**
* What one product's declaration comes to, under whichever basis the officer chose.
*
* RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital,
* so each product's amount follows directly from the capital its members hold.
*
* PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure
* at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the
* same across products — any other split would pay two members with identical holdings different amounts purely
* because they hold different share classes.
*/
private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital,
final BigDecimal totalWeightedCapital, final BigDecimal profitPool) {
if (ratePercentage == null || weightedCapital.signum() <= 0) {
return BigDecimal.ZERO;
}
if (BASIS_PERCENT_OF_PROFIT.equals(basis)) {
if (profitPool == null || totalWeightedCapital.signum() <= 0) {
return BigDecimal.ZERO;
}
return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP);
}
return percentageOf(weightedCapital, ratePercentage);
}
private BigDecimal percentageOf(final BigDecimal base, final BigDecimal percentage) {
return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private String normaliseBasis(final String basis) {
return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL;
}
/**
* The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of
* it, and what is left for a dividend.
*
* Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the
* society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a
* large one that has already been heavily appropriated — and the reserve and education percentages are
* configurable, so the amounts they take cannot be inferred from the result either.
*/
private static final class ProfitWaterfall {
private final BigDecimal netProfit;
private final BigDecimal statutoryReservePercentage;
private final BigDecimal statutoryReserveAmount;
private final BigDecimal educationFundPercentage;
private final BigDecimal educationFundAmount;
private final BigDecimal distributableProfit;
private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage,
final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage,
final BigDecimal educationFundAmount, final BigDecimal distributableProfit) {
this.netProfit = netProfit;
this.statutoryReservePercentage = statutoryReservePercentage;
this.statutoryReserveAmount = statutoryReserveAmount;
this.educationFundPercentage = educationFundPercentage;
this.educationFundAmount = educationFundAmount;
this.distributableProfit = distributableProfit;
}
private Map<String, Object> asMap() {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("netProfit", netProfit);
out.put("statutoryReservePercentage", statutoryReservePercentage);
out.put("statutoryReserveAmount", statutoryReserveAmount);
out.put("educationFundPercentage", educationFundPercentage);
out.put("educationFundAmount", educationFundAmount);
out.put("distributableProfit", distributableProfit);
return out;
}
}
/**
* The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education
* fund, which are appropriated before any dividend.
*
* Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never
* disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than
* guessed at from a P&L that is still moving.
*/
private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) {
final BigDecimal netProfit = netProfitForPeriod(from, to);
final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
// Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and
// nothing to distribute, so the whole waterfall collapses to zero rather than producing a
// negative "reserve" that would read as money owed back to the society.
if (netProfit == null || netProfit.signum() <= 0) {
final BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero,
zero);
}
final BigDecimal reserve = percentageOf(netProfit, reservePercentage);
final BigDecimal educationFund = percentageOf(netProfit, educationPercentage);
return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund,
netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO));
}
/**
* The period's net profit, read exactly the way the declaration validator reads it.
*
* This deliberately mirrors {@code ShareProductWritePlatformServiceJpaRepositoryImpl.profitForPeriod} rather than
* reading the profit GL directly. A closed year's profit lives in the profit GL, but a year is only "closed" once
* it is registered in {@code m_income_profit_transfer} — and after closing, income-less-expense for the period is
* zero, because closing is what zeroed those GLs. Reading the GL without that check would show a healthy profit on
* the preview and then have the declaration rejected for having none, which is exactly the contradiction an
* officer cannot act on.
*/
private BigDecimal netProfitForPeriod(final LocalDate from, final LocalDate to) {
final int fyStartYear = from.getMonthValue() >= 4 ? from.getYear() : from.getYear() - 1;
final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100);
final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1);
final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31);
if (!from.isAfter(fyStart) && !to.isBefore(fyEnd)) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount != null && closedCount > 0) {
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
}
}
// The year is still open, so the profit is whatever the P&L says right now.
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense"));
}
/** A configured appropriation rate, or zero when it has not been set. */
private BigDecimal configuredPercentage(final String configurationName) {
final List<BigDecimal> values = jdbcTemplate.queryForList(
"SELECT value FROM c_configuration WHERE name = ? AND enabled = true", BigDecimal.class, configurationName);
return values.isEmpty() || values.get(0) == null ? BigDecimal.ZERO : values.get(0);
}
/**
* Paid-up capital x days held, across a product's eligible accounts.
*
* Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a
* holding already in force at the period start is treated as starting there, and a purchase that has not cleared
* the product's minimum active period is skipped.
*/
private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to,
final int minimumActivePeriod) {
final BigDecimal total = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(t.amount * (? - GREATEST(t.transaction_date, ?))), 0)
FROM m_share_account sa
JOIN m_share_account_transactions t ON t.account_id = sa.id
WHERE sa.product_id = ? AND sa.status_enum = 300
AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true
AND t.transaction_date <= ?
AND (? - GREATEST(t.transaction_date, ?)) >= ?
""", BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to),
java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod);
return total == null ? BigDecimal.ZERO : total;
}
private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) {
final Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_share_product_dividend_pay_out
WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", Integer.class, productId, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from));
return count != null && count > 0;
}
private Map<String, Object> skipRow(final Long productId, final String productName, final String reason) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("productId", productId);
row.put("productName", productName);
row.put("reason", reason);
return row;
}
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
private String financialYearOf(final String isoDate) {
final LocalDate date = LocalDate.parse(isoDate);
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private LocalDate parseDate(final String isoDate) {
try {
return LocalDate.parse(isoDate);
} catch (final RuntimeException e) {
throw apiError(400, "Dates must be in yyyy-MM-dd format. Received: " + isoDate);
}
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw apiError(400, "Request body is required.");
}
try {
return com.google.gson.JsonParser.parseString(body).getAsJsonObject();
} catch (final RuntimeException e) {
throw apiError(400, "Request body is not valid JSON.");
}
}
private String getString(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
return null;
}
final String value = json.get(field).getAsString().trim();
return value.isEmpty() ? null : value;
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountDividendData;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountDividendReadPlatformService;
import org.apache.fineract.portfolio.shareproducts.data.ShareProductDividendPayOutData;
import org.apache.fineract.portfolio.shareproducts.service.ShareDividendRoundingResidueReadService;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductDividendReadPlatformService;
import org.springframework.stereotype.Component;
@@ -61,6 +62,7 @@ public class ShareDividendApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final ShareAccountDividendReadPlatformService shareAccountDividendReadPlatformService;
private final ShareProductDividendReadPlatformService shareProductDividendReadPlatformService;
private final ShareDividendRoundingResidueReadService shareDividendRoundingResidueReadService;
private final SqlValidator sqlValidator;
@GET
@@ -100,6 +102,37 @@ public class ShareDividendApiResource {
return this.toApiAccountDetailJsonSerializer.serialize(dividendDetails);
}
/**
* What rounding left behind on one declaration.
*
* Members' entitlements are rounded down to whole rupees so each can be paid out as a Demand Draft, which leaves a
* few paise undistributed on every declaration. This reports that gap for the declaration being viewed — declared
* versus allocated versus residue — so the difference between the board's figure and the sum of the member rows on
* screen is explained rather than left as an apparent discrepancy.
*/
@GET
@Path("{dividendId}/rounding-residue")
@Produces({ MediaType.APPLICATION_JSON })
public String retrieveRoundingResidue(@PathParam("productId") final Long productId,
@PathParam("dividendId") final Long dividendId) {
this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveOne(dividendId));
}
/**
* Rounding residue across every declaration of this product, newest period first.
*
* Kept per declaration and never deleted, so an auditor can be shown years afterwards exactly where each
* declaration's undistributed paise went and which journal entry moved them.
*/
@GET
@Path("rounding-residue")
@Produces({ MediaType.APPLICATION_JSON })
public String retrieveRoundingResidueHistory(@PathParam("productId") final Long productId) {
this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveAllForProduct(productId));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,262 @@
/**
* 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.shareproducts.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* What a given dividend RATE actually costs for one share product over one period.
*
* "9% dividend" in an AGM resolution is a promise about each member's own paid-up capital: a member holding 10,000 of
* share capital for the full year receives 900. That is not the same thing as distributing 9% of the society's profit —
* under a profit-share the amount an individual member receives depends on how many OTHER members hold shares, so the
* same declared "9%" pays a different rate every year. This endpoint sizes the pool the promised way, so the amount
* declared is whatever it costs to actually honour the rate.
*
* The weighting matches {@code ShareProductDividendAssembler} exactly — same eligible-account rule, same
* minimum-active-period skip, same share-days accumulation — because that assembler is what splits the pool across
* members once the declaration is created. If the two disagreed, the rate quoted here would not be the rate members
* were paid.
*/
@Path("/v1/shareproduct/{productId}/dividend-capital-preview")
@Component
@Tag(name = "Share Dividend Capital Preview", description = "Weighted paid-up share capital for a period, and the pool required to pay a given rate per annum on it.")
@RequiredArgsConstructor
public class ShareDividendCapitalPreviewApiResource {
/** Denominator for an annual rate; matches the assembler's exact-day arithmetic. */
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("productId") final Long productId, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("ratePercentage") final BigDecimal ratePercentage) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
if (to.before(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
final Map<String, Object> product = productOrThrow(productId);
final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0
: new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue();
final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients"));
final List<Map<String, Object>> weighted = weightedCapitalPerAccount(productId, from, to, minimumActivePeriod,
allowInactiveClients);
BigDecimal totalCapitalDays = BigDecimal.ZERO;
long eligibleAccounts = 0;
for (final Map<String, Object> account : weighted) {
final BigDecimal capitalDays = (BigDecimal) account.get("capitalDays");
totalCapitalDays = totalCapitalDays.add(capitalDays);
if (capitalDays.signum() > 0) {
eligibleAccounts++;
}
}
// Capital-days / 365 is the average capital that was actually at risk across the period —
// the base an annual rate applies to. A member who held for half the year contributes half
// their capital to it, which is exactly the pro-rata entitlement they are owed.
final BigDecimal weightedCapital = totalCapitalDays.divide(DAYS_IN_YEAR, 6, RoundingMode.HALF_UP);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("productId", productId);
response.put("productName", product.get("name"));
response.put("fromDate", fromDate);
response.put("toDate", toDate);
response.put("minimumActivePeriodDays", minimumActivePeriod);
response.put("eligibleAccounts", eligibleAccounts);
response.put("totalCapitalDays", totalCapitalDays);
response.put("weightedCapital", weightedCapital);
if (ratePercentage != null) {
response.put("ratePercentage", ratePercentage);
response.put("requiredAmount",
weightedCapital.multiply(ratePercentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP));
} else {
response.put("ratePercentage", null);
response.put("requiredAmount", null);
}
return gson.toJson(response);
}
private Map<String, Object> productOrThrow(final Long productId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT name, minimum_active_period_frequency, allow_dividends_inactive_clients
FROM m_share_product WHERE id = ?
""", productId);
if (rows.isEmpty()) {
throw apiError(404, "Share product " + productId + " does not exist");
}
return rows.get(0);
}
/**
* Paid-up capital x days held, per eligible account.
*
* Mirrors {@code ShareProductDividendAssembler.calculateNumberOfShareDays}: only APPROVED transactions count,
* charge payments are excluded, a purchase already in force at the period start is treated as starting at the
* period start, and a purchase that has not yet cleared the minimum active period is skipped entirely. Redemptions
* reduce the running holding and the holding is floored at zero, so a redemption of shares whose purchase was
* skipped cannot drive an account negative — the same defensive floor the assembler carries.
*
* Unlike the assembler this weights by AMOUNT rather than share count, since a rate is quoted on paid-up capital
* and unit price can differ between purchases.
*/
private List<Map<String, Object>> weightedCapitalPerAccount(final Long productId, final Date from, final Date to,
final int minimumActivePeriod, final boolean allowInactiveClients) {
// 300 = ACTIVE, 600 = CLOSED (ShareAccountStatusType); 300 = APPROVED transaction status,
// 500 = PURCHASED, 700 = CHARGE_PAYMENT (PurchasedSharesStatusType).
final String accountStatusClause = allowInactiveClients
? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) "
: " AND sa.status_enum = 300 ";
final String sql = """
SELECT sa.id AS account_id, t.transaction_date, t.total_shares, t.unit_price, t.amount, t.type_enum
FROM m_share_account sa
JOIN m_share_account_transactions t ON t.account_id = sa.id
WHERE sa.product_id = ?
""" + accountStatusClause + """
AND t.status_enum = 300
AND t.type_enum <> 700
AND t.transaction_date <= ?
ORDER BY sa.id, t.transaction_date, t.id
""";
final List<Map<String, Object>> rows = allowInactiveClients ? jdbcTemplate.queryForList(sql, productId, from, to)
: jdbcTemplate.queryForList(sql, productId, to);
final Map<Long, BigDecimal> capitalDaysPerAccount = new LinkedHashMap<>();
Long currentAccountId = null;
BigDecimal runningCapital = BigDecimal.ZERO;
java.time.LocalDate lastAppliedDate = null;
BigDecimal accumulated = BigDecimal.ZERO;
final java.time.LocalDate periodStart = from.toLocalDate();
final java.time.LocalDate periodEnd = to.toLocalDate();
for (final Map<String, Object> row : rows) {
final Long accountId = ((Number) row.get("account_id")).longValue();
if (!accountId.equals(currentAccountId)) {
if (currentAccountId != null) {
capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital));
}
currentAccountId = accountId;
runningCapital = BigDecimal.ZERO;
lastAppliedDate = null;
accumulated = BigDecimal.ZERO;
}
java.time.LocalDate transactionDate = ((Date) row.get("transaction_date")).toLocalDate();
if (transactionDate.isBefore(periodStart)) {
transactionDate = periodStart;
}
final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500;
if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) {
continue;
}
if (lastAppliedDate != null) {
final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, transactionDate);
accumulated = accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days)));
}
lastAppliedDate = transactionDate;
final BigDecimal transactionCapital = capitalOf(row);
runningCapital = isPurchase ? runningCapital.add(transactionCapital) : runningCapital.subtract(transactionCapital);
if (runningCapital.signum() < 0) {
runningCapital = BigDecimal.ZERO;
}
}
if (currentAccountId != null) {
capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital));
}
return capitalDaysPerAccount.entrySet().stream().map(entry -> {
final Map<String, Object> account = new LinkedHashMap<>();
account.put("accountId", entry.getKey());
account.put("capitalDays", entry.getValue());
return account;
}).toList();
}
/** Carries the final holding forward to the period end, the way the assembler's tail does. */
private BigDecimal closeAccount(final BigDecimal accumulated, final java.time.LocalDate lastAppliedDate,
final java.time.LocalDate periodEnd, final BigDecimal runningCapital) {
if (lastAppliedDate == null) {
return accumulated;
}
final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, periodEnd);
return accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days)));
}
/**
* Paid-up capital the transaction moved. `amount` is authoritative where present; shares x unit price is the
* fallback for rows that predate amount being populated.
*/
private BigDecimal capitalOf(final Map<String, Object> row) {
final Object amount = row.get("amount");
if (amount != null) {
return (BigDecimal) amount;
}
final Object shares = row.get("total_shares");
final Object unitPrice = row.get("unit_price");
if (shares == null || unitPrice == null) {
return BigDecimal.ZERO;
}
return BigDecimal.valueOf(((Number) shares).longValue()).multiply((BigDecimal) unitPrice);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -82,4 +82,22 @@ public class ShareProductDividendPayOutDetails extends AbstractAuditableCustom {
return ShareProductDividendStatusType.fromInt(this.status);
}
/**
* Entitlements dropped for falling under the minimum payable amount, before they were zeroed.
*
* Not persisted — it exists only to carry the figure from the assembler, which is the only place that knows what a
* dropped member would have received, to the residue record written straight afterwards. Reading it back from
* m_share_account_dividend_details is impossible: those rows hold the zeroed amount, not the original.
*/
@jakarta.persistence.Transient
private BigDecimal belowMinimumAmount = BigDecimal.ZERO;
public BigDecimal getBelowMinimumAmount() {
return this.belowMinimumAmount == null ? BigDecimal.ZERO : this.belowMinimumAmount;
}
public void addBelowMinimumAmount(final BigDecimal amount) {
this.belowMinimumAmount = getBelowMinimumAmount().add(amount);
}
}
@@ -0,0 +1,113 @@
/**
* 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.shareproducts.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Reports what a declaration did NOT pay out to members, and why.
*
* Two things hold an amount back. Every entitlement is rounded down to a whole rupee, because a dividend is paid as a
* Demand Draft and a branch cannot draw one for 421.32. And an entitlement below the minimum payable amount is dropped
* entirely, because drawing a draft for a few rupees costs the society more than the draft is worth.
*
* Both go to Bank Profit at declaration time — rounding down and dropping below the floor mean no member was ever
* promised that money, so unlike an unclaimed dividend there is nobody who could claim it and no three-year wait. The
* record is what persists: it is what answers an auditor asking why a declaration of 1,538.91 allocated 1,538 to
* members, per declaration, with the journal entry that moved the difference.
*/
@Service
@RequiredArgsConstructor
public class ShareDividendRoundingResidueReadService {
private final JdbcTemplate jdbcTemplate;
private static final String COLUMNS = """
r.id, r.dividend_pay_out_id, r.product_id, p.name AS product_name,
r.declared_amount, r.allocated_amount, r.residue_amount, r.member_count,
r.below_minimum_count, r.below_minimum_amount,
r.currency_code, r.period_start_date, r.period_end_date,
r.swept_to_profit_date, r.gl_transaction_id
""";
/** The residue for one declaration, or null if none was recorded (declarations made before this was tracked). */
public Map<String, Object> retrieveOne(final Long dividendPayOutId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """
FROM m_share_dividend_rounding_residue r
JOIN m_share_product p ON p.id = r.product_id
WHERE r.dividend_pay_out_id = ?
""", dividendPayOutId);
return rows.isEmpty() ? null : toResponse(rows.get(0));
}
/** Every declaration's residue for one product, newest period first, with a running total. */
public Map<String, Object> retrieveAllForProduct(final Long productId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """
FROM m_share_dividend_rounding_residue r
JOIN m_share_product p ON p.id = r.product_id
WHERE r.product_id = ?
ORDER BY r.period_end_date DESC, r.id DESC
""", productId);
final List<Map<String, Object>> entries = new ArrayList<>();
BigDecimal totalResidue = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
entries.add(toResponse(row));
totalResidue = totalResidue.add((BigDecimal) row.get("residue_amount"));
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("productId", productId);
response.put("declarationCount", entries.size());
response.put("totalResidueSweptToProfit", totalResidue);
response.put("entries", entries);
return response;
}
private Map<String, Object> toResponse(final Map<String, Object> row) {
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("id", row.get("id"));
entry.put("declarationId", row.get("dividend_pay_out_id"));
entry.put("productId", row.get("product_id"));
entry.put("productName", row.get("product_name"));
entry.put("declaredAmount", row.get("declared_amount"));
entry.put("allocatedAmount", row.get("allocated_amount"));
entry.put("residueAmount", row.get("residue_amount"));
entry.put("memberCount", row.get("member_count"));
// How much of the residue came from entitlements too small to draw a draft for, as opposed
// to paise shaved off larger ones — the two are worth telling apart when reviewing whether
// the minimum is set sensibly.
entry.put("belowMinimumCount", row.get("below_minimum_count"));
entry.put("belowMinimumAmount", row.get("below_minimum_amount"));
entry.put("currencyCode", row.get("currency_code"));
entry.put("periodStartDate", String.valueOf(row.get("period_start_date")));
entry.put("periodEndDate", String.valueOf(row.get("period_end_date")));
final Object sweptDate = row.get("swept_to_profit_date");
entry.put("sweptToProfitDate", sweptDate == null ? null : String.valueOf(sweptDate));
entry.put("glTransactionId", row.get("gl_transaction_id"));
return entry;
}
}
@@ -19,9 +19,11 @@
package org.apache.fineract.portfolio.shareproducts.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -36,12 +38,20 @@ import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatf
import org.apache.fineract.portfolio.shareproducts.data.ShareProductData;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails;
import org.apache.fineract.portfolio.shareproducts.exception.ShareAccountsNotFoundException;
import org.springframework.jdbc.core.JdbcTemplate;
@RequiredArgsConstructor
public class ShareProductDividendAssembler {
/** Smallest entitlement worth paying out; seeded by changelog 0480. */
private static final String CONFIG_MINIMUM_PAYABLE_AMOUNT = "dividend-minimum-payable-amount";
/** Entitlements are rounded down to a multiple of this; seeded by changelog 0480. */
private static final String CONFIG_ROUNDING_MULTIPLE = "dividend-rounding-multiple";
private final ShareProductReadPlatformService shareProductReadPlatformService;
private final ShareAccountReadPlatformService shareAccountReadPlatformService;
private final JdbcTemplate jdbcTemplate;
public ShareProductDividendPayOutDetails calculateDividends(final Long productId, final BigDecimal amount,
final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) {
@@ -67,12 +77,28 @@ public class ShareProductDividendAssembler {
if (numberOfShareDays > 0) {
double amountPerShareDay = amount.doubleValue() / numberOfShareDays;
final BigDecimal minimumPayableAmount = configuredAmount(CONFIG_MINIMUM_PAYABLE_AMOUNT);
final BigDecimal roundingMultiple = configuredAmount(CONFIG_ROUNDING_MULTIPLE);
productDividendPayOutDetails = new ShareProductDividendPayOutDetails(productId, Money.of(currency, amount).getAmount(),
dividendPeriodStartDate, dividendPeriodEndDate);
for (ShareAccountData accountData : shareAccountDatas) {
long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId());
double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay;
final Money accountAmount = Money.of(currency, BigDecimal.valueOf(amountForAccount));
// Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87
// becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts
// cash in notes and coins, so round figures are what it can actually hand over.
// Rounding down rather than to nearest also guarantees the society never commits more
// than it declared: allocations always sum to at or below the declared amount.
BigDecimal roundedAmount = roundDownToMultiple(BigDecimal.valueOf(amountForAccount), roundingMultiple);
// An entitlement below the minimum is dropped to zero rather than paid: a draft costs
// the society a leaflet, stationery and counter time, so drawing one for a few rupees
// hands over less than it costs. What is dropped is swept to profit with the rounding
// residue and reported per declaration, so it is never silently lost.
if (roundedAmount.compareTo(minimumPayableAmount) < 0) {
productDividendPayOutDetails.addBelowMinimumAmount(roundedAmount);
roundedAmount = BigDecimal.ZERO;
}
final Money accountAmount = Money.of(currency, roundedAmount);
ShareAccountDividendDetails dividendDetails = new ShareAccountDividendDetails(accountData.getId(),
accountAmount.getAmount(), productDividendPayOutDetails);
productDividendPayOutDetails.getAccountDividendDetails().add(dividendDetails);
@@ -82,6 +108,33 @@ public class ShareProductDividendAssembler {
return productDividendPayOutDetails;
}
/**
* Rounds an entitlement down to the nearest multiple: at a multiple of 5, 546.789 becomes 545 and 121.87 becomes
* 120.
*
* A multiple of zero or one (or a disabled configuration row) means no multiple applies, and the amount is simply
* floored to a whole rupee — paise still cannot be paid out.
*/
private BigDecimal roundDownToMultiple(final BigDecimal amount, final BigDecimal multiple) {
if (multiple.compareTo(BigDecimal.ONE) <= 0) {
return amount.setScale(0, RoundingMode.FLOOR);
}
return amount.divide(multiple, 0, RoundingMode.FLOOR).multiply(multiple);
}
/**
* An amount from configuration. A missing or disabled row means the rule does not apply to this society, which is
* zero — not a failure.
*/
private BigDecimal configuredAmount(final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
private long calculateNumberOfShareDays(final LocalDate postingDate, final LocalDate lastDividendPostDate,
final int minimumActivePeriod, final Collection<ShareAccountData> shareAccountDatas,
final Map<Long, Long> numberOfSharesdaysPerAccount) {
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.shareproducts.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -43,7 +44,14 @@ import org.apache.fineract.infrastructure.event.business.domain.share.ShareProdu
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository;
import org.apache.fineract.portfolio.shareproducts.constants.ShareProductApiConstants;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails;
@@ -54,14 +62,6 @@ import org.apache.fineract.portfolio.shareproducts.exception.ShareProductEditPen
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductNoPendingEditException;
import org.apache.fineract.portfolio.shareproducts.exception.ShareProductSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.shareproducts.serialization.ShareProductDataSerializer;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository;
import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper;
import com.google.gson.JsonObject;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,8 +90,40 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
// accounting module into this constructor just for two int constants.
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
/**
* The year's closed-out profit a dividend is declared out of; kept in step with
* IncomeProfitTransferService.PROFIT_GL_CODE.
*/
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
/**
* Where profit settles once a declaration has been approved — the undistributed remainder and the rounding
* residue. Nothing here ever enters a later dividend calculation.
*/
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
// Statutory appropriation funds, seeded by changelog 0478. Resolved by GL code rather than a
// financial activity because they are society-specific funds with no native Fineract activity.
private static final String STATUTORY_RESERVE_GL_CODE = "STATRES01";
private static final String EDUCATION_FUND_GL_CODE = "EDUFUND01";
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final String JOURNAL_ENTRY_INSERT_SQL = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
private static final String CONFIG_STATUTORY_RESERVE_PERCENTAGE = "dividend-statutory-reserve-percentage";
private static final String CONFIG_EDUCATION_FUND_PERCENTAGE = "dividend-education-fund-percentage";
@Override
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
@@ -160,6 +192,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount);
final String financialYear = financialYearOf(dividendPeriodEndDate);
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
@@ -169,7 +203,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate,
dividendPeriodEndDate, financialYear);
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount,
financialYear);
recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount,
dividendPayOutDetails.getBelowMinimumAmount(), dividendPeriodStartDate, dividendPeriodEndDate);
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
@@ -186,10 +225,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
/**
* A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is
* a real reservation of that period's profit. Two declarations covering the same days would
* pay members twice for the same period, so any overlap against an existing declaration for
* this product is blocked outright.
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is a real reservation of
* that period's profit. Two declarations covering the same days would pay members twice for the same period, so any
* overlap against an existing declaration for this product is blocked outright.
*/
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate) {
@@ -206,15 +244,443 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
/**
* Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own
* Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just
* silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry
* pair rather than through the transaction-bound AccountingProcessorHelper (which expects a
* loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if
* the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity
* Mappings yet, so dividend declaration itself is never blocked by missing GL setup.
* Moves the profit this declaration did not distribute out of the distributable pool, once the declaration is
* approved.
*
* <pre>
* Dr Bank Profit (Non-Distributable) what was left after the dividend
* Cr Bank Profit
* </pre>
*
* Approval is the point at which the year's distribution is settled: the members' entitlements are fixed and no
* further dividend can be declared out of that profit. Leaving the remainder in the Non-Distributable account
* would carry it into the next year's balance, and the next declaration would size itself on profit that had
* already been through a distribution decision — offering the same money twice.
*
* The remainder is what the whole distributable pool for the period leaves after this declaration and any others
* already made against it, which is the same figure the declaration screen showed as available. Dated to the
* period's end rather than the day of approval, so it files under the year it belongs to: a 2025-26 dividend is
* often approved months into 2026-27.
*
* Skipped silently when there is nothing left, or when the GL setup is incomplete — approval must not be blocked
* by an account that has not been mapped.
*/
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
private void closeOffUndistributedProfit(final ShareProductDividendPayOutDetails declaration) {
final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate();
if (periodEndDate == null) {
return;
}
final Map<String, Object> period = jdbcTemplate.queryForMap(
"SELECT dividend_period_start_date, product_id FROM m_share_product_dividend_pay_out WHERE id = ?", declaration.getId());
final LocalDate periodStartDate = ((java.sql.Date) period.get("dividend_period_start_date")).toLocalDate();
final java.sql.Date from = java.sql.Date.valueOf(periodStartDate);
final java.sql.Date to = java.sql.Date.valueOf(periodEndDate);
final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to);
if (netProfit.signum() <= 0) {
return;
}
final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE))
.subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE));
// Every declaration against an overlapping period, this one included — the pool is
// society-wide, so a second product's dividend has already consumed part of it.
final BigDecimal declaredTotal = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out
WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", BigDecimal.class, to, from);
final BigDecimal remainder = distributable.subtract(declaredTotal);
if (remainder.signum() <= 0) {
return;
}
final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE);
final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
if (distributableAccountId == null || settledAccountId == null) {
return;
}
final String financialYear = financialYearOf(periodEndDate);
// Dated to the day of approval, not the period end. Closing off the remainder is an act of
// the approval, so it must appear in the ledger on the day the officer performed it —
// back-dating it to 31 March files it months before it happened, where nobody reviewing the
// approval would find it. The financial year it belongs to is carried in the description.
postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")),
"DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()),
"FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #"
+ declaration.getId());
}
private String currencyCodeOfProduct(final Object productId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT currency_code FROM m_share_product WHERE id = ?",
productId);
return rows.isEmpty() ? "INR" : (String) rows.get(0).get("currency_code");
}
/**
* Records what rounding left behind, and moves it to the society's own profit.
*
* Each member's entitlement is rounded down to a whole rupee (see {@code ShareProductDividendAssembler}) because a
* dividend is paid out as a Demand Draft and a branch cannot draw one for 421.32. The paise that fall off are not
* anybody's money: rounding DOWN means no member was ever promised them, so unlike an unclaimed dividend there is
* nobody who could turn up to claim it and no reason to hold it for three years. It goes to Bank Profit now.
*
* The row, however, is permanent. It is what answers an auditor years later asking why a declaration of 1,538.91
* only allocated 1,538 to members — per declaration, with the journal entry that moved the difference.
*
* Skipped silently if the profit GL is not configured, leaving {@code swept_to_profit_date} null so the residue can
* be identified and swept once it is — the same posture the Reserve entry above takes, so incomplete GL setup never
* blocks a declaration.
*/
private void recordAndSweepRoundingResidue(final Long dividendPayOutId, final Long productId, final String currencyCode,
final BigDecimal declaredAmount, final BigDecimal belowMinimumAmount, final LocalDate periodStartDate,
final LocalDate periodEndDate) {
final Map<String, Object> allocation = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(amount), 0) AS allocated,
COUNT(*) FILTER (WHERE amount > 0) AS member_count,
COUNT(*) FILTER (WHERE amount = 0) AS below_minimum_count
FROM m_share_account_dividend_details WHERE dividend_pay_out_id = ?
""", dividendPayOutId);
final BigDecimal allocatedAmount = (BigDecimal) allocation.get("allocated");
final int memberCount = ((Number) allocation.get("member_count")).intValue();
// Members allocated exactly zero were dropped for falling under the minimum payable amount
// (an ineligible member never reaches allocation at all — the assembler skips them earlier),
// so this counts precisely those whose entitlement was too small to draw a draft for.
final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue();
final BigDecimal residue = declaredAmount.subtract(allocatedAmount);
// Swept to the settled account, not back to the pool it came from: the residue belongs to the
// year that was just declared, and returning it to the distributable balance would put it
// straight back into what the next declaration measures.
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// A residue of zero still gets its row: "nothing was left over" is itself the answer to the
// audit question, and its absence would be indistinguishable from the sweep never running.
//
// The row is written now, at declaration, because it records what the allocation arithmetic
// produced. The journal entry that moves the money waits for approval — see
// sweepRoundingResidueOnApproval — because it debits Dividend Payable, and that account
// carries nothing until approval creates the liability. swept_to_profit_date stays null
// until then, which is what marks a residue as recorded-but-not-yet-swept.
jdbcTemplate.update("""
INSERT INTO m_share_dividend_rounding_residue
(dividend_pay_out_id, product_id, declared_amount, allocated_amount, residue_amount, member_count,
below_minimum_count, below_minimum_amount, currency_code, period_start_date, period_end_date,
swept_to_profit_date, gl_transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
""", dividendPayOutId, productId, declaredAmount, allocatedAmount, residue, memberCount, belowMinimumCount,
belowMinimumAmount, currencyCode, java.sql.Date.valueOf(periodStartDate), java.sql.Date.valueOf(periodEndDate));
}
/**
* Moves what rounding left behind to the society's own profit, once the dividend is approved.
*
* Split from the recording of the residue because this entry debits Dividend Payable, and that liability does not
* exist until approval creates it. Sweeping at declaration time would drive Dividend Payable negative — the ledger
* would show the society owing members less than nothing while the dividend was still awaiting approval.
*
* Dated to the day of approval, alongside the release to Dividend Payable it takes back from. Both are acts of the
* approval and belong on the ledger the day it happened; the financial year they relate to is carried in the
* description rather than in the entry date.
*/
private void sweepRoundingResidueOnApproval(final Long dividendPayOutId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT residue_amount, currency_code, period_end_date
FROM m_share_dividend_rounding_residue
WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL
""", dividendPayOutId);
if (rows.isEmpty()) {
return;
}
final Map<String, Object> row = rows.get(0);
final BigDecimal residue = (BigDecimal) row.get("residue_amount");
if (residue == null || residue.signum() <= 0) {
return;
}
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// Left unswept rather than forced through: swept_to_profit_date stays null so the residue is
// still identifiable and can be swept once the GL setup is completed.
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
return;
}
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
final String transactionId = "DIVROUND" + dividendPayOutId;
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
// that will never be paid to a member and returns it to retained profit.
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId,
sweptDate,
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId);
jdbcTemplate.update("""
UPDATE m_share_dividend_rounding_residue
SET swept_to_profit_date = ?, gl_transaction_id = ?
WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL
""", sweptDate, transactionId, dividendPayOutId);
}
/**
* A co-operative society may only distribute what is left of net profit after the statutory appropriations the
* Co-operative Societies Act requires first — a Statutory Reserve Fund transfer and a state-federation
* education-fund contribution — and after every dividend already declared against an overlapping period for ANY
* share product, since the profit pool is society-wide rather than per-product. Without this, two products could
* each be declared 60% of profit and the society would have committed 120% of money it never had.
*
* The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is
* enforced here because that screen is not the only way a declaration can be created, and a stale screen must not
* be able to commit an over-declaration.
*
* Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has
* an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other
* declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor.
*/
private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) {
final java.sql.Date from = java.sql.Date.valueOf(dividendPeriodStartDate);
final java.sql.Date to = java.sql.Date.valueOf(dividendPeriodEndDate);
final BigDecimal netProfit = profitForPeriod(dividendPeriodStartDate, dividendPeriodEndDate, from, to);
if (netProfit.signum() <= 0) {
throw new DividentProcessingException("no.distributable.profit", "The bank made no profit between " + dividendPeriodStartDate
+ " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period.");
}
final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE);
final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE);
final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund);
// Every product competes for the same distributable profit, so this deliberately does NOT
// filter by product_id — unlike validateNoOverlappingDividendPeriod, which is about paying
// the same product's members twice for the same days.
final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out
WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", BigDecimal.class, to, from);
final BigDecimal available = distributableProfit.subtract(alreadyDeclared);
if (dividendAmount.compareTo(available) > 0) {
throw new DividentProcessingException("exceeds.distributable.profit",
"This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit
+ " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves "
+ distributableProfit + ", of which " + alreadyDeclared + " is already declared — only "
+ available.max(BigDecimal.ZERO) + " remains available.");
}
}
/**
* The profit a dividend for this period may be declared out of.
*
* For a full financial year that has been closed, that is the balance of the Bank Profit equity GL — the audited
* figure the books settled on once every income and expense GL was closed into it. Reading the closed figure
* rather than re-deriving income-less-expense keeps the declaration tied to the books: an adjusted or reversed
* closing entry moves the dividend ceiling with it.
*
* Anything else is an interim declaration inside a year that is still open, where the live P&L is the only figure
* there is. Mirrors {@code DistributableProfitApiResource} so the screen's ceiling and this one cannot diverge.
*/
private BigDecimal profitForPeriod(final LocalDate periodStart, final LocalDate periodEnd, final java.sql.Date from,
final java.sql.Date to) {
final int fyStartYear = periodStart.getMonthValue() >= 4 ? periodStart.getYear() : periodStart.getYear() - 1;
final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100);
final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1);
final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31);
if (!periodStart.isAfter(fyStart) && !periodEnd.isBefore(fyEnd)) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount != null && closedCount > 0) {
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
}
}
final Map<String, Object> profitRow = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", from, to);
return ((BigDecimal) profitRow.get("total_income")).subtract((BigDecimal) profitRow.get("total_expense"));
}
/**
* The Indian financial year a date falls in, as "2026-27". A dividend is labelled by the year its period closes,
* which is how a member and an auditor both refer to it.
*/
private String financialYearOf(final LocalDate date) {
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
private BigDecimal percentageOfNetProfit(final BigDecimal netProfit, final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
final BigDecimal percentage = new BigDecimal(String.valueOf(rows.get(0).get("value")));
return netProfit.multiply(percentage).divide(BigDecimal.valueOf(100), 6, java.math.RoundingMode.HALF_UP);
}
/**
* Posts the statutory appropriations that must come out of profit before a dividend may be declared.
*
* <pre>
* Dr Bank Profit Cr Statutory Reserve Fund (25% of net profit, equity — retained permanently)
* Dr Bank Profit Cr Education Fund Payable (1%, a liability owed to the state federation)
* </pre>
*
* Until now these only ever reduced the ceiling the declaration screen enforced — the money was never actually
* moved, so the Statutory Reserve Fund and Education Fund GLs stayed empty and the balance sheet did not show the
* transfers the Co-operative Societies Act requires. Posting them here makes the appropriation real.
*
* The rates come from c_configuration (changelog 0478), so a society on a different state Act, or one whose rates
* are revised, changes the setting rather than the code. A disabled or missing row means that appropriation does not
* apply and nothing is posted for it.
*
* Posted once per declaration and only for the appropriations that resolve — a society whose GL setup is incomplete
* still gets its dividend declared rather than being blocked.
*/
private void postStatutoryAppropriations(final Long dividendPayOutId, final String currencyCode, final LocalDate periodStartDate,
final LocalDate periodEndDate, final String financialYear) {
final java.sql.Date from = java.sql.Date.valueOf(periodStartDate);
final java.sql.Date to = java.sql.Date.valueOf(periodEndDate);
final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to);
if (netProfit.signum() <= 0) {
return;
}
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE);
if (bankProfitAccountId == null) {
return;
}
postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(STATUTORY_RESERVE_GL_CODE),
percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE), currencyCode, financialYear,
"statutory reserve", "DIVSTATRES");
postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(EDUCATION_FUND_GL_CODE),
percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE), currencyCode, financialYear,
"education fund", "DIVEDUFUND");
}
/** One appropriation out of profit: debit Bank Profit, credit the fund it is set aside into. */
private void postAppropriation(final Long dividendPayOutId, final Long bankProfitAccountId, final Long fundAccountId,
final BigDecimal amount, final String currencyCode, final String financialYear, final String label,
final String transactionIdPrefix) {
if (fundAccountId == null || amount.signum() <= 0) {
return;
}
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
postJournalEntryPair(bankProfitAccountId, fundAccountId, amount, currencyCode, transactionIdPrefix + dividendPayOutId, entryDate,
"FY " + financialYear + " " + label + " appropriated from profit — declaration #" + dividendPayOutId);
}
private Long glAccountIdByCode(final String glCode) {
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
.orElse(null);
}
/**
* Declaring a dividend charges it against the society's retained profit and creates the obligation to members.
*
* <pre>
* Dr Bank Profit Cr Reserve for Dividend — profit set aside for this declaration
* Dr Reserve for Dividend Cr Dividend Payable — released as what the society owes members
* </pre>
*
* Routed through Reserve for Dividend rather than posted straight across, so the appropriation of profit and the
* obligation to members are two separate, visible acts — which is how a board minutes them. Reserve nets to nil
* once both legs post; its credit column is the cumulative profit ever set aside for dividends.
*
* The money still starts in Bank Profit and, if never claimed, returns there via Dividend Forfeited Income at
* year-end closing, so the round trip reconciles.
*
* Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which
* expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting
* Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by
* incomplete GL setup.
*/
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount,
final String financialYear) {
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE);
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final Long reserveAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
.stream().findFirst().orElse(null);
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
final String transactionId = "DIVDECL" + dividendPayOutId;
// The financial year is stamped into every dividend journal entry so the ledger itself can
// answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared
// across years, and without the year on the line the balances only ever give a running total.
final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId;
if (reserveAccountId == null) {
postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate,
description);
return;
}
// Only the appropriation posts here. The release to members is a separate act that waits for
// approval — see releaseReserveToDividendPayable. Posting both at declaration would put a
// liability to members on the books before anyone had approved paying them.
postJournalEntryPair(bankProfitAccountId, reserveAccountId, amount, currencyCode, transactionId, sqlEntryDate,
description + " (profit set aside into Reserve)");
}
/**
* The second leg of the dividend appropriation, posted on approval:
*
* <pre>
* Dr Reserve for Dividend Cr Dividend Payable — released as what the society owes members
* </pre>
*
* Split from the declaration leg deliberately. A declared dividend is a proposal: profit is set aside, but the
* society does not yet owe anyone anything. The obligation to members only exists once the board has approved it,
* so Dividend Payable — a liability — must not appear on the balance sheet before that. Between declaration and
* approval the amount sits visibly in Reserve for Dividend, which is exactly what that account is for.
*
* Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside.
*/
private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) {
final Long reserveAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
@@ -223,27 +689,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend
// Payable, so the liability is on the books and there is nothing left to release.
if (reserveAccountId == null || payableDividendsAccountId == null) {
return;
}
// Read back from the row rather than the entity: the amount and product are not exposed as
// getters, and this matches how closeOffUndistributedProfit reads the same declaration.
final Map<String, Object> row = jdbcTemplate.queryForMap(
"SELECT amount, product_id, dividend_period_end_date FROM m_share_product_dividend_pay_out WHERE id = ?",
declaration.getId());
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount == null || amount.signum() <= 0) {
return;
}
final ShareProduct product = this.repository.findOneWithNotFoundDetection(((Number) row.get("product_id")).longValue());
final String financialYear = financialYearOf(((java.sql.Date) row.get("dividend_period_end_date")).toLocalDate());
final String description = "FY " + financialYear + " dividend approved — declaration #" + declaration.getId()
+ " (Reserve released to members)";
postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(),
"DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description);
}
/**
* Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction
* the money travelled and the ledger reads in order.
*/
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) {
final AppUser currentUser = this.context.authenticatedUser();
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVRES" + dividendPayOutId;
final String description = "Reserve for Dividend declaration #" + dividendPayOutId;
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
""";
// Debit reduces the equity Reserve; credit increases the Dividend Payable liability.
jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT,
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
}
@Override
@@ -256,7 +739,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
dividendPayOutDetails.approveDividendPayout();
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
releaseReserveToDividendPayable(dividendPayOutDetails);
sweepRoundingResidueOnApproval(PayOutDetailId);
makeDividendsClaimable(PayOutDetailId);
closeOffUndistributedProfit(dividendPayOutDetails);
return new CommandProcessingResultBuilder() //
.withEntityId(PayOutDetailId) //
.build();
@@ -267,16 +753,14 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
/**
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account.
* Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at
* declaration time) until the member actually comes to claim it -- at which point it can be
* split between clearing an overdue loan, a savings credit, or cash (see
* {@code DividendClaimRequestApiResource}). This just marks each member's share of the
* declaration POSTED (their entitlement is locked in, no more recalculation) and opens a
* 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather
* than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This
* is cheap enough (no per-member external command calls, just two bulk statements) to run
* synchronously in the approve request itself -- no background job needed.
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account. Every member's
* entitlement stays purely a "Dividend Payable" GL liability (already posted at declaration time) until the member
* actually comes to claim it -- at which point it can be split between clearing an overdue loan, a savings credit,
* or cash (see {@code DividendClaimRequestApiResource}). This just marks each member's share of the declaration
* POSTED (their entitlement is locked in, no more recalculation) and opens a 3-year window (see
* {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather than a savings hold) after which an
* unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls,
* just two bulk statements) to run synchronously in the approve request itself -- no background job needed.
*/
private void makeDividendsClaimable(final Long payOutDetailId) {
final LocalDate today = DateUtils.getBusinessLocalDate();
@@ -337,8 +821,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_SHARE, productId, maker);
final java.util.Set<String> submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command, pendingEdit.getShareOnlyFields());
final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command, pendingEdit.getGlAccountMappingFields());
final java.util.Set<String> submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command,
pendingEdit.getShareOnlyFields());
final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command,
pendingEdit.getGlAccountMappingFields());
if (glMappingSubmitted) {
submitted.add("glAccountMapping");
}
@@ -66,8 +66,8 @@ public class ShareProductsConfiguration {
@Bean
@ConditionalOnMissingBean(ShareProductDividendAssembler.class)
public ShareProductDividendAssembler shareProductDividendAssembler(ShareProductReadPlatformService shareProductReadPlatformService,
ShareAccountReadPlatformService shareAccountReadPlatformService) {
return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService);
ShareAccountReadPlatformService shareAccountReadPlatformService, JdbcTemplate jdbcTemplate) {
return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService, jdbcTemplate);
}
@@ -0,0 +1,429 @@
/**
* 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.voucher.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Physical verification of a voucher against the paper slip, and the files scanned in alongside it.
*
* The counter reads the signed slip, compares it with what the system recorded, and marks the
* voucher VERIFIED or — when the two disagree — EXCEPTION, carrying the amount the slip actually
* shows and a remark. Whoever investigates then has both figures and the scanned slip in one place,
* instead of having to work out whether the system posted the wrong amount or the slip was written
* up wrongly.
*/
@Path("/v1/vouchers")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class VoucherVerificationApiResource {
private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXCEPTION = "EXCEPTION";
/** Guards against a scanned slip large enough to strain the request or the row. */
private static final long MAX_ATTACHMENT_BYTES = 10L * 1024 * 1024;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ── POST /v1/vouchers/{voucherNo}/verify ──────────────────────────────────
// The voucher number travels in the body, not the path: numbers are formatted
// CR/001/2108/0001 and Spring Security's strict firewall rejects the encoded slashes.
@POST
@Path("verify")
@Consumes(MediaType.APPLICATION_JSON)
public String verify(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final var root = this.fromJsonHelper.parse(body);
final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required.");
}
String status = this.fromJsonHelper.extractStringNamed("status", root);
status = status == null ? "" : status.trim().toUpperCase();
if (!STATUS_VERIFIED.equals(status) && !STATUS_EXCEPTION.equals(status)) {
throw new PlatformDataIntegrityException("error.msg.voucher.verify.status.invalid",
"Status must be either VERIFIED or EXCEPTION.");
}
final BigDecimal slipAmount = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("slipAmount", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
// An exception always has to say why, because it is a flag someone else has to act on.
// It does not always mean the amounts differ: a voucher whose figures agree can still be
// wrong — an unsigned slip, the wrong account, a member who disputes the entry — so the
// slip amount is recorded rather than demanded as proof of a difference.
if (STATUS_EXCEPTION.equals(status) && (remarks == null || remarks.isBlank())) {
throw new PlatformDataIntegrityException("error.msg.voucher.verify.remarks.required",
"A remark is required when raising an exception, so it can be investigated.");
}
final BigDecimal systemAmount = findVoucherAmount(voucherNo);
if (systemAmount == null) {
throw new PlatformDataIntegrityException("error.msg.voucher.not.found",
"Voucher " + voucherNo + " does not exist in any register.");
}
// Upsert: a voucher carries exactly one verdict, so re-checking a slip corrects the earlier
// one rather than leaving two rows to disagree with each other.
jdbcTemplate.update("""
INSERT INTO m_voucher_verification
(voucher_no, status, system_amount, slip_amount, remarks, verified_by, verified_by_name, verified_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT (voucher_no) DO UPDATE SET
status = EXCLUDED.status,
system_amount = EXCLUDED.system_amount,
slip_amount = EXCLUDED.slip_amount,
remarks = EXCLUDED.remarks,
verified_by = EXCLUDED.verified_by,
verified_by_name = EXCLUDED.verified_by_name,
verified_at = EXCLUDED.verified_at
""",
voucherNo, status, systemAmount,
STATUS_EXCEPTION.equals(status) ? slipAmount : null,
remarks, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
final JsonObject res = new JsonObject();
res.addProperty("voucherNo", voucherNo);
res.addProperty("status", status);
res.addProperty("systemAmount", systemAmount);
// Only reported when a slip figure was actually supplied — an exception raised over
// something other than the amount carries none, and there is no difference to state.
if (STATUS_EXCEPTION.equals(status) && slipAmount != null) {
res.addProperty("slipAmount", slipAmount);
res.addProperty("difference", slipAmount.subtract(systemAmount));
}
return gson.toJson(res);
}
// ── Exception queue, for whoever has to sort them out ─────────────────────
/**
* Open exceptions across every voucher, newest first — the queue a clerk or manager works
* through. Not scoped to a single day: an exception raised on Friday still needs answering on
* Monday, so limiting this to today would quietly hide the ones that matter most.
*
* Each row carries both amounts, the cashier's remark and the account it belongs to, so whoever
* picks it up can sit down with the cashier already knowing what to look at.
*/
@GET
@Path("exceptions")
public String exceptions(@QueryParam("status") final String status) {
final AppUser currentUser = this.context.authenticatedUser();
// Anyone who can see a voucher can see that it was questioned; only a checker may close it.
final boolean openOnly = status == null || status.isBlank() || "OPEN".equalsIgnoreCase(status);
final String resolutionFilter = openOnly ? " AND v.resolution_status IS NULL"
: ("RESOLVED".equalsIgnoreCase(status) ? " AND v.resolution_status IS NOT NULL" : "");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT v.voucher_no, v.system_amount, v.slip_amount, v.remarks,
v.verified_by_name, v.verified_at,
v.resolution_status, v.resolution_notes, v.resolved_by_name, v.resolved_at,
COALESCE(d.account_no, w.account_no, b.gl_account_no) AS account_no,
COALESCE(d.client_name, w.client_name, b.gl_account_name) AS party,
COALESCE(d.cashier_name, w.clerk_name, b.clerk_name) AS raised_against,
COALESCE(va.n, 0) AS attachment_count
FROM m_voucher_verification v
LEFT JOIN m_cashier_deposit_request d ON d.voucher_no = v.voucher_no
LEFT JOIN m_clerk_withdrawal_request w ON w.voucher_no = v.voucher_no
LEFT JOIN m_clerk_disbursement_request b ON b.voucher_no = v.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = v.voucher_no
WHERE v.status = 'EXCEPTION'""" + resolutionFilter + """
ORDER BY v.verified_at DESC
""");
final List<Map<String, Object>> out = new java.util.ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("voucherNo", r.get("voucher_no"));
m.put("systemAmount", r.get("system_amount"));
m.put("slipAmount", r.get("slip_amount"));
m.put("remarks", r.get("remarks"));
m.put("raisedByName", r.get("verified_by_name"));
m.put("raisedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
m.put("accountNo", r.get("account_no"));
m.put("party", r.get("party"));
m.put("raisedAgainst", r.get("raised_against"));
m.put("attachmentCount", r.get("attachment_count"));
m.put("resolutionStatus", r.get("resolution_status"));
m.put("resolutionNotes", r.get("resolution_notes"));
m.put("resolvedByName", r.get("resolved_by_name"));
m.put("resolvedAt", r.get("resolved_at") == null ? null : r.get("resolved_at").toString());
out.add(m);
}
return gson.toJson(Map.of("canResolve", isCheckerRole(currentUser), "exceptions", out, "count", out.size()));
}
/**
* Closes an exception with a conclusion. Open to a clerk, head cashier or manager — but not to
* the cashier who raised the flag, who must not be the one who decides it was nothing.
*/
@POST
@Path("exceptions/resolve")
@Consumes(MediaType.APPLICATION_JSON)
public String resolveException(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
if (!isCheckerRole(currentUser)) {
throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolve.forbidden",
"Only a Clerk, Head Cashier or Bank Manager can close a voucher exception.");
}
final var root = this.fromJsonHelper.parse(body);
final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root);
String resolution = this.fromJsonHelper.extractStringNamed("resolutionStatus", root);
final String notes = this.fromJsonHelper.extractStringNamed("resolutionNotes", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required.");
}
resolution = resolution == null ? "" : resolution.trim().toUpperCase();
if (!List.of("RESOLVED_SYSTEM_ERROR", "RESOLVED_SLIP_ERROR", "RESOLVED_NO_ACTION").contains(resolution)) {
throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolution.invalid",
"Choose whether the system entry was wrong, the slip was wrong, or no action is needed.");
}
if (notes == null || notes.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.exception.notes.required",
"Record what was found and what was done about it.");
}
// Closing an exception needs paper behind it — the corrected slip, a signed note, whatever
// was relied on. Without that the resolution is one person's word, which is precisely what
// the exception was raised to avoid. Upload happens first, then the close.
final Integer attachments = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_voucher_attachment WHERE voucher_no = ?", Integer.class, voucherNo);
if (attachments == null || attachments == 0) {
throw new PlatformDataIntegrityException("error.msg.voucher.exception.document.required",
"Attach the supporting document for this resolution before closing the exception.");
}
final int updated = jdbcTemplate.update("""
UPDATE m_voucher_verification
SET resolution_status = ?, resolution_notes = ?, resolved_by = ?,
resolved_by_name = ?, resolved_at = ?
WHERE voucher_no = ? AND status = 'EXCEPTION'
""",
resolution, notes, currentUser.getId(), currentUser.getDisplayName(),
Timestamp.from(Instant.now()), voucherNo);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.voucher.exception.not.found",
"No open exception found for voucher " + voucherNo + ".");
}
return gson.toJson(Map.of("voucherNo", voucherNo, "resolutionStatus", resolution, "resolved", true));
}
/**
* Who may close an exception: a clerk, head cashier or manager. The clerk is included because
* most differences are settled at the counter between the two of them — sending every one up to
* a manager would leave routine keying slips sitting open for days.
*
* The cashier who raised the flag is still not among them: whoever spots a difference should not
* also be the one who rules it was nothing.
*/
private boolean isCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk"));
}
// There is deliberately no endpoint to clear a verdict. Deleting one would delete the evidence
// that a voucher was ever questioned, which is the opposite of what an investigation needs — a
// wrong verdict is corrected by re-verifying (the upsert above) or by raising an exception
// against it, both of which leave a record of who decided what.
// ── Attachments ───────────────────────────────────────────────────────────
/**
* Accepts one scanned slip as base64 in a JSON body rather than a multipart upload: the
* browser already holds the file as a data URL from the file input, and this keeps the call on
* the same JSON path as every other request the screen makes.
*/
@POST
@Path("attachments")
@Consumes(MediaType.APPLICATION_JSON)
public String upload(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final var root = this.fromJsonHelper.parse(body);
final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root);
final String fileName = this.fromJsonHelper.extractStringNamed("fileName", root);
final String contentType = this.fromJsonHelper.extractStringNamed("contentType", root);
final String description = this.fromJsonHelper.extractStringNamed("description", root);
String base64 = this.fromJsonHelper.extractStringNamed("fileData", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required.");
}
if (fileName == null || fileName.isBlank() || base64 == null || base64.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.file.required",
"A file name and file contents are required.");
}
if (findVoucherAmount(voucherNo) == null) {
throw new PlatformDataIntegrityException("error.msg.voucher.not.found",
"Voucher " + voucherNo + " does not exist in any register.");
}
// A file input hands over "data:image/png;base64,AAAA..." — keep only the payload.
final int comma = base64.indexOf(',');
if (base64.startsWith("data:") && comma > 0) {
base64 = base64.substring(comma + 1);
}
final byte[] data;
try {
data = java.util.Base64.getDecoder().decode(base64);
} catch (final IllegalArgumentException e) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.invalid",
"The file could not be read. Please try uploading it again.");
}
if (data.length == 0) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.empty", "The file is empty.");
}
if (data.length > MAX_ATTACHMENT_BYTES) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.too.large",
"The file is larger than 10 MB. Please scan it at a lower resolution.");
}
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_voucher_attachment
(voucher_no, file_name, content_type, file_size, description, file_data,
uploaded_by, uploaded_by_name, uploaded_at)
VALUES (?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, voucherNo, fileName, contentType, (long) data.length, description, data,
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", id, "voucherNo", voucherNo, "fileName", fileName, "fileSize", data.length));
}
/** Metadata only — the bytes are fetched one at a time by {@link #download}. */
@GET
@Path("attachments")
public String list(@QueryParam("voucherNo") final String voucherNo) {
this.context.authenticatedUser();
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required.");
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, file_name, content_type, file_size, description,
uploaded_by_name, uploaded_at
FROM m_voucher_attachment
WHERE voucher_no = ?
ORDER BY id DESC
""", voucherNo);
final List<Map<String, Object>> out = new java.util.ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
m.put("voucherNo", r.get("voucher_no"));
m.put("fileName", r.get("file_name"));
m.put("contentType", r.get("content_type"));
m.put("fileSize", r.get("file_size"));
m.put("description", r.get("description"));
m.put("uploadedByName", r.get("uploaded_by_name"));
final Object at = r.get("uploaded_at");
m.put("uploadedAt", at == null ? null : at.toString());
out.add(m);
}
return gson.toJson(Map.of("voucherNo", voucherNo, "attachments", out));
}
@GET
@Path("attachments/{id}/download")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public Response download(@PathParam("id") final Long id) {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT file_name, content_type, file_data FROM m_voucher_attachment WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found",
"Attachment " + id + " does not exist.");
}
final Map<String, Object> row = rows.get(0);
final byte[] data = (byte[]) row.get("file_data");
final String contentType = row.get("content_type") != null ? (String) row.get("content_type")
: MediaType.APPLICATION_OCTET_STREAM;
return Response.ok(data, contentType)
.header("Content-Disposition", "attachment; filename=\"" + row.get("file_name") + "\"").build();
}
@DELETE
@Path("attachments/{id}")
public String delete(@PathParam("id") final Long id) {
this.context.authenticatedUser();
final int removed = jdbcTemplate.update("DELETE FROM m_voucher_attachment WHERE id = ?", id);
if (removed == 0) {
throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found",
"Attachment " + id + " does not exist.");
}
return gson.toJson(Map.of("resourceId", id, "deleted", true));
}
/**
* The amount the system holds for a voucher, looked up across all three registers. Returns
* null when no register knows the number, which is how callers detect a bad voucher.
*/
private BigDecimal findVoucherAmount(final String voucherNo) {
final List<BigDecimal> found = jdbcTemplate.query("""
SELECT amount FROM m_cashier_deposit_request WHERE voucher_no = ?
UNION ALL
SELECT amount FROM m_clerk_withdrawal_request WHERE voucher_no = ?
UNION ALL
SELECT dr_amount FROM m_clerk_disbursement_request WHERE voucher_no = ?
""", (rs, n) -> rs.getBigDecimal(1), voucherNo, voucherNo, voucherNo);
return found.isEmpty() ? null : found.get(0);
}
}
@@ -19,22 +19,35 @@
package org.apache.fineract.portfolio.voucher.service;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Central voucher register (m_voucher). Every maker-checker cash transaction
* (clerk withdrawal request, cashier deposit request) gets a globally unique
* voucher number from this single sequence, so a withdrawal and a deposit can
* never share the same voucher number. The voucher is also stamped onto the
* resulting savings transaction (m_savings_account_transaction.voucher_no)
* once the manager approves and the transaction is created.
* (clerk withdrawal request, cashier deposit request) gets a voucher number in
* the form PREFIX/BRANCH/DDMM/NNNN — for example CR/001/2108/0001 for a deposit
* and DR/001/2108/0001 for a withdrawal on 21 August at branch 001.
*
* The trailing counter restarts at 0001 every day, independently for each
* (prefix, branch) pair, so a deposit and a withdrawal at the same branch on the
* same day never share a number, and neither does the same direction at another
* branch. Counters live in m_voucher_sequence and are bumped under a row lock,
* because a daily-reset counter cannot lean on the m_voucher autoincrement PK
* for uniqueness the way the old flat "000001" scheme did.
*
* The voucher is also stamped onto the resulting savings transaction
* (m_savings_account_transaction.voucher_no) once the manager approves and the
* transaction is created.
*/
@Service
@RequiredArgsConstructor
@@ -46,30 +59,90 @@ public class VoucherService {
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
/** Money into an account — the CR side of the slip. */
public static final String PREFIX_CREDIT = "CR";
/** Money out of an account — the DR side of the slip. */
public static final String PREFIX_DEBIT = "DR";
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
private final JdbcTemplate jdbcTemplate;
/**
* Creates a voucher row and returns the formatted voucher number ("000001").
* The number is the zero-padded primary key, so uniqueness is guaranteed by
* the database sequence — no counter table, no race conditions.
* Maps a voucher type to its slip-side prefix. A deposit credits the member's
* account (CR); a withdrawal and a disbursement both take money out (DR).
*/
private static String prefixFor(final String voucherType) {
return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT;
}
/** Branch code as it appears in the UI's BRANCH dropdown: office id zero-padded to 3 digits. */
private static String branchCode(final Long officeId) {
return String.format("%03d", officeId != null ? officeId : 1L);
}
/**
* Reserves the next counter value for one (prefix, branch, day) series.
*
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use
* of the day and increments it thereafter. Being a single statement, it takes
* and releases the row lock atomically, so two cashiers submitting at the same
* moment are serialised by the database and can never be handed the same
* number — no explicit SELECT ... FOR UPDATE needed.
*/
private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) {
return jdbcTemplate.queryForObject("""
INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number)
VALUES (?,?,?,1)
ON CONFLICT (prefix, branch_code, series_date)
DO UPDATE SET last_number = m_voucher_sequence.last_number + 1
RETURNING last_number
""",
Integer.class, prefix, branch, Date.valueOf(day));
}
/**
* Creates a voucher row and returns the formatted voucher number, e.g.
* "CR/001/2108/0001". The branch is taken from the acting user's own office
* rather than from any branch the screen happens to have selected, so the
* voucher always names the branch that actually handled the cash.
*/
public String createVoucher(final String voucherType, final String referenceType,
final Long referenceId, final BigDecimal amount, final AppUser user) {
return createVoucher(voucherType, referenceType, referenceId, amount, user, prefixFor(voucherType));
}
final Long voucherId = jdbcTemplate.queryForObject("""
/**
* As {@link #createVoucher}, but with the slip side stated explicitly. The
* disbursement register covers three directions under one voucher type —
* money out of a GL (DISBURSEMENT), money into a GL (COLLECTION) and an
* account-to-account move (BANK_TRANSFER) — so the caller decides whether
* this one reads as CR or DR rather than inferring it from the type alone.
*/
public String createVoucher(final String voucherType, final String referenceType,
final Long referenceId, final BigDecimal amount, final AppUser user,
final String prefix) {
// Tenant timezone, not the server's — the day the counter resets on has to be the
// branch's business day, otherwise a late-evening voucher lands in the wrong series.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null);
final int serial = nextSeriesNumber(prefix, branch, today);
final String voucherNo = String.format("%s/%s/%s/%04d",
prefix, branch, today.format(DAY_MONTH), serial);
jdbcTemplate.update("""
INSERT INTO m_voucher
(voucher_type, reference_type, reference_id, amount, created_by, created_by_username, created_at)
VALUES (?,?,?,?,?,?,?)
RETURNING id
(voucher_no, voucher_type, reference_type, reference_id, amount,
created_by, created_by_username, created_at)
VALUES (?,?,?,?,?,?,?,?)
""",
Long.class,
voucherType, referenceType, referenceId, amount,
voucherNo, voucherType, referenceType, referenceId, amount,
user.getId(), user.getUsername(), Timestamp.from(Instant.now())
);
final String voucherNo = String.format("%06d", voucherId);
jdbcTemplate.update("UPDATE m_voucher SET voucher_no = ? WHERE id = ?", voucherNo, voucherId);
return voucherNo;
}
@@ -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:313863}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
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}
@@ -407,7 +407,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:313863}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
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}
@@ -537,4 +537,34 @@
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_add_resolution_date_to_share_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_make_share_account_savings_optional.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_create_share_advance_collection.xml" relativeToChangelogFile="true"/>
<include file="parts/0473_add_collection_applied_to_share_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_add_voucher_no_to_share_advance_collection.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_add_share_applied_to_deposit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_create_income_profit_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0477_add_share_entry_fee_profit_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0478_seed_dividend_appropriation_accounts_and_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0479_dividend_claim_by_demand_draft.xml" relativeToChangelogFile="true"/>
<include file="parts/0480_create_dividend_rounding_residue.xml" relativeToChangelogFile="true"/>
<include file="parts/0481_seed_dividend_paid_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0482_create_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0483_seed_share_savings_debit_payment_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0484_voucher_no_dated_format.xml" relativeToChangelogFile="true"/>
<include file="parts/0485_backfill_flat_share_charge_amounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0486_unpay_unsettled_share_activation_charges.xml" relativeToChangelogFile="true"/>
<include file="parts/0487_create_voucher_verification.xml" relativeToChangelogFile="true"/>
<include file="parts/0488_add_voucher_exception_resolution.xml" relativeToChangelogFile="true"/>
<include file="parts/0489_create_branch_day_register.xml" relativeToChangelogFile="true"/>
<include file="parts/0490_seed_bank_profit_non_distributable_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_add_reinvested_from_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_add_fd_account_number_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_add_renewal_cycle_no_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0473_create_fd_renewal_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_add_is_active_to_interest_rate_chart.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_create_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml" relativeToChangelogFile="true"/>
<include file="parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0478_add_fd_payment_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- On "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure, a brand new FD/RD account is
auto-created with no visible link back to the matured account it came from, so staff had no way
to trace one from the other. This column, set on the NEW account, points at the OLD one; the OLD
account's own "reinvested into" side is derived by looking up whichever row has this account's id
here, so only one column is needed to track the relationship in both directions. -->
<changeSet id="0470_add_reinvested_from_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="reinvested_from_savings_id"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="reinvested_from_savings_id" type="BIGINT"/>
</addColumn>
<createIndex indexName="reinvested_from_savings_id" tableName="m_deposit_account_term_and_preclosure">
<column name="reinvested_from_savings_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="reinvested_from_savings_id"
baseTableName="m_deposit_account_term_and_preclosure"
constraintName="m_deposit_account_term_and_preclosure_reinvested_from_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?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">
<!-- Date of the Board Resolution authorising the share account. Nullable so share accounts
created before this column existed remain valid without a fabricated backfill date;
required for newly created accounts by the application-level validator. -->
<changeSet author="fineract" id="1">
<addColumn tableName="m_share_account">
<column name="resolution_date" type="DATE"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- FD renewal ("Re-Invest") always opens a brand new account with its own account_no, since
account_no is unique and the matured account keeps its own row for audit history. Customers
and staff still expect to recognise "their FD" by one number across renewals though, so this
column carries the ORIGINAL account's account_no forward onto every account created by
reinvesting it (and its reinvestments, and so on). Left null on an account that has never been
reinvested; the read side falls back to that account's own account_no in that case, so only
accounts past their first renewal need this populated. -->
<changeSet id="0471_add_fd_account_number_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="fd_account_number"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="fd_account_number" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A member may hold shares without a linked savings account: the account is only needed to
credit a dividend payout, and dividends are held as a "Dividend Payable" GL liability until
claimed rather than credited up front. Existing rows are unaffected. -->
<changeSet author="fineract" id="1">
<dropNotNullConstraint tableName="m_share_account" columnName="savings_account_id" columnDataType="BIGINT"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Which renewal cycle of one logical FD this account row represents: 0 for the account the
customer originally opened, 1 for the first renewal, and so on. Together with
fd_account_number (the stable customer-facing number) this turns the chain of per-term
account rows into an explicit "one FD, many cycles" model, so statements and the account
list can present a single FD with numbered terms instead of unrelated accounts.
Defaults to 0 so every pre-existing account reads as its own first cycle. -->
<changeSet id="0472_add_renewal_cycle_no_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="renewal_cycle_no"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="renewal_cycle_no" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Cash taken from a member for share purchase BEFORE any share account exists.
The money is parked in the Share Sundry liability GL and stays identifiable by the
receipt number recorded here, so the unapplied rows in this table always reconcile
against the Sundry balance. Applying a receipt to a share account moves the money
out of Sundry into share capital. -->
<changeSet author="fineract" id="1">
<createTable tableName="m_share_advance_collection">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="receipt_no" type="VARCHAR(40)">
<constraints nullable="false" unique="true"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="collection_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<!-- UNAPPLIED = money sitting in Sundry; APPLIED = moved to share capital;
REFUNDED = returned to the member without shares being issued. -->
<column name="status" type="VARCHAR(20)" defaultValue="UNAPPLIED">
<constraints nullable="false"/>
</column>
<column name="gl_transaction_id" type="VARCHAR(60)">
<constraints nullable="false"/>
</column>
<column name="applied_share_account_id" type="BIGINT"/>
<column name="applied_on_date" type="DATE"/>
<column name="applied_gl_transaction_id" type="VARCHAR(60)"/>
<column name="collected_by_user" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="collected_by_name" type="VARCHAR(100)"/>
<column name="note" type="VARCHAR(500)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_share_advance_collection" baseColumnNames="client_id"
constraintName="fk_share_advance_collection_client"
referencedTableName="m_client" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_share_advance_collection" baseColumnNames="applied_share_account_id"
constraintName="fk_share_advance_collection_share_account"
referencedTableName="m_share_account" referencedColumnNames="id"/>
<!-- Reconciliation lookup: unapplied receipts for a client, and the Sundry total. -->
<createIndex tableName="m_share_advance_collection" indexName="idx_share_advance_collection_client_status">
<column name="client_id"/>
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- True once an advance collection has been applied to this account: the money has moved
from Share Sundry into share capital. Until then the account is a registration only,
and its purchase transaction is hidden from the passbook and transactions views.
Backfilled to true for accounts that pre-date this flow so their history keeps showing. -->
<changeSet author="fineract" id="1">
<addColumn tableName="m_share_account">
<column name="collection_applied" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="2">
<update tableName="m_share_account">
<column name="collection_applied" valueBoolean="true"/>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Audit trail of FD renewals, one row per term of one logical FD.
A renewal opens each term as its own deposit instance (its own m_savings_account row, so that
term keeps its own immutable ledger, interest postings and GL entries), while the customer
keeps seeing one FD number. That leaves the renewal EVENT itself unrecorded: how much
interest the previous term earned, what it matured at, how much was carried forward, at what
rate, whether the renewal was automatic or done by a teller, and who did it. Deriving that by
joining account rows loses the intent; this table records it explicitly.
fd_account_number is the stable customer-facing number shared by every term, so one FD's
whole history is one indexed lookup. -->
<changeSet id="0473_create_fd_renewal_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_account_number" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="original_savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="previous_savings_account_id" type="BIGINT"/>
<column name="previous_renewal_id" type="BIGINT"/>
<column name="renewal_no" type="INT">
<constraints nullable="false"/>
</column>
<column name="previous_principal" type="DECIMAL(19,6)"/>
<column name="interest_earned" type="DECIMAL(19,6)"/>
<column name="maturity_amount" type="DECIMAL(19,6)"/>
<column name="renewed_amount" type="DECIMAL(19,6)"/>
<column name="interest_rate" type="DECIMAL(19,6)"/>
<column name="start_date" type="DATE"/>
<column name="maturity_date" type="DATE"/>
<column name="tenure" type="INT"/>
<column name="tenure_frequency_enum" type="SMALLINT"/>
<column name="renewal_type" type="VARCHAR(10)"/>
<column name="renewal_status" type="VARCHAR(20)"/>
<column name="renewed_on" type="DATE"/>
<column name="created_by_id" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_history_fd_account_number" tableName="m_fd_renewal_history">
<column name="fd_account_number"/>
</createIndex>
<createIndex indexName="idx_fd_renewal_history_savings_account_id" tableName="m_fd_renewal_history">
<column name="savings_account_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="original_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_original_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="previous_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_prev_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Explicit lifecycle flag on a product's interest-rate chart.
Which chart prices a deposit was previously inferred purely from its from/end dates, so
retiring a rate version meant remembering to set an end date on it, and a version left
open-ended was treated as valid only up to today. Staff think in terms of "these are the
rates we currently offer", so the chart now says so directly and date ranges stay as the
historical record.
Defaults to true so every existing chart keeps working; the changeset then retires any chart
whose end date has already passed, since those were superseded by definition.
Deliberately on m_interest_rate_chart only, not on the shared InterestRateChartFields
embeddable, which is also embedded into m_savings_account_interest_rate_chart (a per-account
snapshot that has no lifecycle of its own). -->
<changeSet id="0474_add_is_active_to_interest_rate_chart" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_interest_rate_chart" columnName="is_active"/>
</not>
</preConditions>
<addColumn tableName="m_interest_rate_chart">
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0474_retire_expired_interest_rate_charts" author="CBS" runOnChange="false">
<update tableName="m_interest_rate_chart">
<column name="is_active" valueBoolean="false"/>
<where>end_date IS NOT NULL AND end_date &lt; CURRENT_DATE</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,42 @@
<?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">
<!-- Voucher number from the central m_voucher register, quoted by the member when the share
account is created. Cash for shares is now taken through the cashier deposit flow, which
issues the voucher; this column is what lets the apply step find the receipt by that
number instead of browsing the member's unapplied list.
Nullable: rows collected before this flow have a receipt_no but no voucher. -->
<changeSet author="fineract" id="1">
<addColumn tableName="m_share_advance_collection">
<column name="voucher_no" type="VARCHAR(40)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="2">
<createIndex tableName="m_share_advance_collection" indexName="idx_share_advance_collection_voucher">
<column name="voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A cashier deposit into the Share Sundry GL is settled against exactly one share account.
These columns record which account consumed the voucher, so the same deposit cannot be
applied twice and the unapplied vouchers still reconcile to the Sundry balance. -->
<changeSet author="fineract" id="1">
<addColumn tableName="m_cashier_deposit_request">
<column name="share_account_id" type="BIGINT"/>
<column name="share_applied_on" type="DATE"/>
<column name="share_applied_txn_id" type="VARCHAR(60)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Archives the FULL tenure-by-rate chart of a term that is about to be superseded by an in-place FD
renewal, not just the single effective rate m_fd_renewal_history already records.
renewInPlace() reuses the same account row across renewals, so the account's own interest-rate
chart (m_savings_account_interest_rate_chart / _slab) is overwritten in place at every renewal;
the closing term's full rate card would otherwise be gone with no way to answer "what did the
whole chart look like during term 2", only "what rate applied". One row per slab, linked to the
m_fd_renewal_history row for the term the chart belonged to. -->
<changeSet id="0475_create_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_chart_slab_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_chart_slab_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_renewal_history_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="chart_name" type="VARCHAR(100)"/>
<column name="description" type="VARCHAR(200)"/>
<column name="period_type_enum" type="SMALLINT"/>
<column name="from_period" type="INT"/>
<column name="to_period" type="INT"/>
<column name="amount_range_from" type="DECIMAL(19,6)"/>
<column name="amount_range_to" type="DECIMAL(19,6)"/>
<column name="annual_interest_rate" type="DECIMAL(19,6)"/>
<column name="currency_code" type="VARCHAR(3)"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_chart_slab_history_renewal_id" tableName="m_fd_renewal_chart_slab_history">
<column name="fd_renewal_history_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="fd_renewal_history_id"
baseTableName="m_fd_renewal_chart_slab_history"
constraintName="m_fd_renewal_chart_slab_history_renewal_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="CASCADE" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_fd_renewal_history" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The senior-citizen premium a slab of an FD's own chart carries, alongside its standard rate.
m_savings_account_interest_rate_slab is populated from InterestRateChartSlabFields, a shared
embeddable also used by the product-level chart table (m_interest_rate_slab), which has no
senior-citizen concept at all; only the CBS base interest-rate master does. Adding the column
to the shared embeddable would have forced it onto that product table too, so it is added here
instead, directly on this table only, and populated by application code independently of the
embeddable. Column stays null for every Savings/RD account and for any FD term priced off a
product chart rather than the base-rate master, which is the existing, unaffected behaviour for
both. -->
<changeSet id="0476_add_senior_citizen_rate_to_account_chart_slab" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_account_interest_rate_slab" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_savings_account_interest_rate_slab">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,89 @@
<?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">
<!-- Financial year-end closing: an income GL (e.g. Share Entry Fee Income) is closed into the
Bank Profit equity GL. One row per posting, so the branch can show how much was moved to
profit, for which year, and by whom — and so the same year cannot be posted twice. -->
<changeSet author="fineract" id="1">
<createTable tableName="m_income_profit_transfer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="financial_year" type="VARCHAR(9)">
<constraints nullable="false"/>
</column>
<column name="income_gl_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="profit_gl_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="from_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="to_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="entry_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<column name="gl_transaction_id" type="VARCHAR(60)">
<constraints nullable="false"/>
</column>
<column name="posted_on_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="posted_by_user" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="posted_by_name" type="VARCHAR(100)"/>
<column name="note" type="VARCHAR(500)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_income_profit_transfer" baseColumnNames="income_gl_id"
constraintName="fk_income_profit_transfer_income_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_income_profit_transfer" baseColumnNames="profit_gl_id"
constraintName="fk_income_profit_transfer_profit_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<!-- A given income GL can only be closed once per financial year. -->
<addUniqueConstraint tableName="m_income_profit_transfer"
columnNames="financial_year, income_gl_id, office_id"
constraintName="uq_income_profit_transfer_year_gl_office"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Carries the senior-citizen premium into the archived rate-chart history too, matching the column
just added to the live account chart in 0476, so a matured term's true historical premium remains
queryable rather than only ever being looked up against whatever the base rate card currently
publishes. -->
<changeSet id="0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_fd_renewal_chart_slab_history" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_fd_renewal_chart_slab_history">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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">
<!-- Financial year-end closing of the share entry fee. Runs at 23:00 on 31 March so the whole
year's fee income is captured before the year rolls over. The job is a no-op if the year
has already been closed or no fee was collected, so a re-run is harmless. -->
<changeSet author="fineract" id="1">
<insert tableName="job">
<column name="name" value="Post Share Entry Fee To Profit"/>
<column name="display_name" value="Post Share Entry Fee To Profit"/>
<column name="short_name" value="PSEFTP"/>
<column name="cron_expression" value="0 0 23 31 3 ? *"/>
<column name="create_time" valueComputed="now()"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Post Share Entry Fee To ProfitJobDetaildefaultSchedulerGroup"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="0"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Permission for the new FD "Payment" command, mirroring PREMATURECLOSE_FIXEDDEPOSITACCOUNT's
grouping and maker-checker setting exactly, so it slots into the same role-assignment screen. -->
<changeSet id="0478_add_fd_payment_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT', 'FIXEDDEPOSITACCOUNT', 'PAYMENT', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT');
</sql>
</changeSet>
<!-- The checker half of the pair above. Because that row is can_maker_checker = true, switching the
global maker-checker setting on makes every FD Payment queue for approval, and approving one
requires the _CHECKER permission by name. Without this row that permission cannot be granted to
any role, so queued payments would be unapprovable by anyone but a super user. Every other
maker-checker FD permission (APPROVE, CLOSE, PREMATURECLOSE, POSTINTEREST, ...) has this twin. -->
<changeSet id="0478_add_fd_payment_checker_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER', 'FIXEDDEPOSITACCOUNT', 'PAYMENT_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A co-operative society cannot pay a dividend straight out of net profit: the Co-operative
Societies Act mandates a Statutory Reserve Fund transfer (>= 25% of net profit) and an
education-fund contribution to the state federation FIRST, and only the residue is
distributable. 0458 already seeded the three accounts for the dividend itself (Reserve for
Dividend / Dividend Payable / Dividend Forfeited Income) but nothing for the appropriations
that legally precede it, so the declaration screen was applying its percentage to raw net
profit and could declare more than the society was permitted to distribute.
Statutory Reserve Fund is EQUITY — it stays in the society permanently and is never paid out
(the opposite of "Reserve for Dividend", which exists precisely to be paid out). The
education fund is a LIABILITY because it is owed to, and eventually remitted to, the state
federation.
These accounts are deliberately NOT wired into acc_gl_financial_activity_account: the
appropriation rates are a board/AGM decision that varies per society and per year, so they
are read from c_configuration below and the entries are posted explicitly at declaration
time rather than derived by the native accounting processors.
-->
<changeSet id="0478_seed_dividend_appropriation_accounts" author="CBS">
<!-- acc_gl_account.gl_code is unique, so a re-run (or a tenant where these were seeded by
hand before this changelog existed) would fail on the insert rather than no-op. -->
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code IN ('STATRES01', 'EDUFUND01')
</sqlCheck>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Statutory Reserve Fund"/>
<column name="gl_code" value="STATRES01"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="3"/>
<column name="description" value="Statutory transfer out of net profit required before any dividend may be declared; retained permanently and never distributed"/>
</insert>
<insert tableName="acc_gl_account">
<column name="name" value="Education Fund Payable"/>
<column name="gl_code" value="EDUFUND01"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="description" value="Contribution out of net profit owed to the state co-operative federation, appropriated before dividend"/>
</insert>
</changeSet>
<!--
Rates and ceilings the declaration screen enforces. All four are society-specific and
change by state and by year, so they are configuration rather than constants. `value` is a
whole-number percentage in every case.
dividend-statutory-reserve-percentage / dividend-education-fund-percentage reduce net profit
to the distributable figure. dividend-maximum-rate-percentage caps the rate the officer may
declare (most state Co-operative Societies Acts cap it at 15%; a few allow 20%). Each is
enabled by default because the statutory minimums apply unless a society has a specific
exemption — a society that genuinely has none can disable the row rather than set it to 0,
which keeps the intent visible in the audit trail.
-->
<changeSet id="0478_seed_dividend_appropriation_config" author="CBS">
<!-- c_configuration.name is unique — same re-run concern as the GL accounts above. -->
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM c_configuration WHERE name IN ('dividend-statutory-reserve-percentage',
'dividend-education-fund-percentage', 'dividend-maximum-rate-percentage')
</sqlCheck>
</preConditions>
<insert tableName="c_configuration">
<column name="name" value="dividend-statutory-reserve-percentage"/>
<column name="value" valueNumeric="25"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="Percentage of net profit transferred to the Statutory Reserve Fund before a dividend may be declared"/>
</insert>
<insert tableName="c_configuration">
<column name="name" value="dividend-education-fund-percentage"/>
<column name="value" valueNumeric="1"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="Percentage of net profit contributed to the state co-operative education fund before a dividend may be declared"/>
</insert>
<insert tableName="c_configuration">
<column name="name" value="dividend-maximum-rate-percentage"/>
<column name="value" valueNumeric="15"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="Maximum dividend rate the society may declare, per the governing Co-operative Societies Act"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A member now collects a dividend as a Demand Draft, and only as a Demand Draft — the savings
credit, cash-at-counter and loan set-off routes are retired. A DD leaves a numbered
instrument the member holds and the branch can trace, which the previous routes did not: a
savings credit vanishes into a running balance, and cash left no counterfoil at all.
The existing loan_apply_amount / savings_apply_amount / cash_apply_amount columns are kept
rather than dropped, because claims already settled through those routes are a permanent
record of how real money moved and must stay readable. New claims write zero to all three
and populate dd_apply_amount instead.
The DD itself is raised through the normal Sponsor DD flow, so it consumes a leaflet from
inventory and carries a DD number like any other draft. What differs is where the money
comes from: a normal DD debits the purchaser (their savings account, or cash they hand
over), whereas this one is funded by the society's own Dividend Payable liability — the
member is being paid, not buying anything. Hence payment_method 'DIVIDEND' on the issue
request, and no charge or GST on it.
-->
<changeSet id="0479_dividend_claim_by_demand_draft" author="CBS">
<addColumn tableName="m_share_dividend_claim_request">
<column name="dd_apply_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Where the draft is payable and who it is drawn in favour of. Asked at claim time
because only the member knows; a DD cannot be printed without them. -->
<column name="dd_beneficiary_name" type="VARCHAR(200)"/>
<column name="dd_payable_at_branch" type="VARCHAR(150)"/>
<column name="dd_payable_at_city" type="VARCHAR(150)"/>
<!-- Set once the manager approves and the draft is actually raised, linking the claim
to the instrument the member walks away with. -->
<column name="dd_issue_request_id" type="BIGINT"/>
<column name="dd_number" type="VARCHAR(50)"/>
</addColumn>
<createIndex tableName="m_share_dividend_claim_request" indexName="idx_dcr_dd_issue_request_id">
<column name="dd_issue_request_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,146 @@
<?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">
<!--
Every member's dividend is now rounded DOWN to a whole rupee, because it is paid out as a
Demand Draft and a branch cannot draw a draft for 421.32. That leaves a few paise per member
undistributed — 0.92 across four members on a 1,538.91 declaration — and this table records
exactly where each of those paise came from.
Unlike an unclaimed dividend, the residue was never any member's entitlement: rounding down
means no member was ever promised it. So it is swept to Bank Profit at declaration time
rather than waiting three years the way m_share_dividend_lock does — there is nobody who
could turn up to claim it. What the three-year retention applies to here is the RECORD: this
row is never deleted, so an auditor asking "the declaration was 1,538.91 but members were
allocated 1,538 — where did the rest go" can be answered years later, per declaration, with
the journal entry that moved it.
One row per declaration, keyed uniquely on it, so a re-run cannot double-sweep.
-->
<changeSet id="0480_create_dividend_rounding_residue" author="CBS">
<createTable tableName="m_share_dividend_rounding_residue">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="dividend_pay_out_id" type="BIGINT">
<constraints nullable="false" unique="true"/>
</column>
<column name="product_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- What the board declared, what members actually got after rounding, and the gap. Stored
rather than derived so the figures stay put even if allocations are later corrected. -->
<column name="declared_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="allocated_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="residue_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="member_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- How much of the residue came from entitlements too small to be worth drawing a draft
for, as opposed to paise shaved off larger ones. Worth telling apart when reviewing
whether the minimum is set sensibly: many small members dropped is a different
problem from a few paise of rounding. -->
<column name="below_minimum_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="below_minimum_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="currency_code" type="VARCHAR(3)"/>
<column name="period_start_date" type="DATE"/>
<column name="period_end_date" type="DATE"/>
<!-- Set when the residue is actually swept to Bank Profit; null until then, so a
declaration whose GL setup was incomplete can be identified and swept later. -->
<column name="swept_to_profit_date" type="DATE"/>
<column name="gl_transaction_id" type="VARCHAR(60)"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_share_dividend_rounding_residue" indexName="idx_sdrr_product_id">
<column name="product_id"/>
</createIndex>
<createIndex tableName="m_share_dividend_rounding_residue" indexName="idx_sdrr_period_end_date">
<column name="period_end_date"/>
</createIndex>
</changeSet>
<!--
The smallest dividend worth paying out. Below this, an entitlement is dropped and swept to
profit with the rounding residue: a Demand Draft costs the society stationery, a leaflet from
inventory and counter time, so drawing one for a handful of rupees costs more than it hands
over. Configuration rather than a constant because it depends on what a draft actually costs
the society, which differs between them and drifts over the years.
Disable the row to pay out every entitlement however small.
-->
<changeSet id="0480_seed_dividend_minimum_payable" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-minimum-payable-amount'
</sqlCheck>
</preConditions>
<insert tableName="c_configuration">
<column name="name" value="dividend-minimum-payable-amount"/>
<column name="value" valueNumeric="10"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="Smallest dividend paid out to a member; below this the entitlement is swept to profit with the rounding residue"/>
</insert>
</changeSet>
<!--
Entitlements are rounded DOWN to a multiple of this, not merely to whole rupees: 546.789
becomes 545 and 121.87 becomes 120. A branch pays a dividend out as a Demand Draft and
counts cash in notes and coins, and settling on round figures keeps both practical.
Rounding down rather than to nearest also guarantees the society never commits more than it
declared — allocations always sum to at or below the declared amount, never above.
Set to 1 to round to whole rupees, or disable the row to allow paise through.
-->
<changeSet id="0480_seed_dividend_rounding_multiple" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-rounding-multiple'
</sqlCheck>
</preConditions>
<insert tableName="c_configuration">
<column name="name" value="dividend-rounding-multiple"/>
<column name="value" valueNumeric="5"/>
<column name="enabled" valueBoolean="true"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="Each member's dividend is rounded down to a multiple of this amount; the difference is swept to profit"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A dedicated GL for dividend actually collected, so the two halves of a declaration are
separately readable at any moment: what has been claimed, and what is still sitting
unclaimed. Until now both lived in Dividend Payable and only its net balance was visible.
declare Dr Reserve for Dividend / Cr Dividend Payable
claim Dr Dividend Payable / Cr Dividend Paid
Dr Dividend Paid / Cr Sponsor DD Payable
Dividend Paid is a LIABILITY, not an expense. Declaring the dividend is what charged it
against profit — that is the Reserve entry above — so treating the payout as an expense
would charge the same money to the society twice. And the debit-then-credit pair at claim
time is deliberate rather than redundant: the society's obligation does not end when the
member takes the draft, it ends when the draft is presented and paid, so the liability has
to move to Sponsor DD Payable rather than disappear. Dividend Paid nets to nil once both
legs post, which is what makes it a clean cumulative measure of what was claimed: its
credit column totals every dividend ever paid out.
Financial activity 202 sits along 201 (Payable Dividends) so the resource code resolves it
the same way and a society can remap it without touching code.
-->
<changeSet id="0481_seed_dividend_paid_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVPAID01'
</sqlCheck>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Dividend Paid"/>
<column name="gl_code" value="DIVPAID01"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="description" value="Dividend claimed by members, routed through on payout; its credit total is the cumulative dividend actually paid"/>
</insert>
<sql>
INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type)
SELECT id, 202 FROM acc_gl_account WHERE gl_code = 'DIVPAID01';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,120 @@
<?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">
<!--
Settling a share application out of the member's own savings account, instead of cash at the
counter.
The existing route (m_share_advance_collection) starts with cash: the cashier receives it,
it parks in Share Sundry against a receipt, and applying that receipt turns it into share
capital. A member who already banks with the society has no reason to withdraw cash and hand
it straight back, so this settles the application directly:
Dr Savings Control (share value + entry fee)
Cr Share Capital (share value)
Cr Entry Fee Income (charge, when one is attached)
No Sundry leg, because nothing is ever held: the debit and the share issue happen together.
Maker-checker, unlike the cash route — a clerk records the request and a manager approves it,
and only the approval touches the member's balance. The cash route needs no checker because
the cashier's receipt is already the second pair of eyes; here nobody has counted anything, so
the debit is the first and only moment the member's money moves.
The withdrawal goes through Fineract's own savings withdrawal command, so the member's
passbook, statement and running balance all reflect it exactly as an over-the-counter
withdrawal would.
-->
<changeSet id="0482_create_share_savings_debit_request" author="CBS">
<createTable tableName="m_share_savings_debit_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Which savings account to debit. Recorded on the request rather than read from the
share account at approval time, so the account the clerk chose is the account
debited even if the linkage is edited in between. -->
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Split out so the approval posts share capital and fee income separately, and so a
later reader can see what the total was made of. -->
<column name="share_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="charge_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<!-- PENDING -> APPROVED (savings debited, shares issued) or REJECTED (nothing moved). -->
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="clerk_name" type="VARCHAR(100)"/>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="manager_id" type="BIGINT"/>
<column name="manager_name" type="VARCHAR(100)"/>
<column name="actioned_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(500)"/>
<!-- The savings withdrawal and GL transaction the approval produced, so the request can
be traced to the money that actually moved. -->
<column name="savings_transaction_id" type="BIGINT"/>
<column name="gl_transaction_id" type="VARCHAR(60)"/>
<column name="note" type="VARCHAR(500)"/>
</createTable>
<createIndex tableName="m_share_savings_debit_request" indexName="idx_ssdr_share_account_id">
<column name="share_account_id"/>
</createIndex>
<createIndex tableName="m_share_savings_debit_request" indexName="idx_ssdr_status">
<column name="status"/>
</createIndex>
<!-- One live request per share account: without this, two clerks could each raise a request
and both approvals would debit the member twice for the same shares. A rejected request
does not block a fresh attempt. -->
<sql>
CREATE UNIQUE INDEX uq_ssdr_one_pending_per_account
ON m_share_savings_debit_request (share_account_id)
WHERE status = 'PENDING';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?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">
<!--
Fineract's savings withdrawal command requires a payment type, and the withdrawal raised
when a share application is settled from the member's savings needs one that describes what
actually happened: an internal book transfer into share capital, not cash over the counter
or a transfer out to anyone.
Seeded rather than reusing "CASH" or "Transfer", both of which would misdescribe it on the
member's statement and in payment-type reporting. Mirrors the payment types already seeded
for the share redemption and dividend claim settlements, which face the same problem.
-->
<changeSet id="0483_seed_share_savings_debit_payment_type" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_payment_type WHERE value = 'Share Application Settlement'
</sqlCheck>
</preConditions>
<insert tableName="m_payment_type">
<column name="value" value="Share Application Settlement"/>
<column name="description"
value="Internal book-transfer settlement of a share application from the member's linked savings account -- no real customer payment instrument"/>
<column name="is_cash_payment" valueBoolean="false"/>
<!-- Sorts to the end of the payment-type list: a teller picking a payment method by
hand should never reach for this one, it exists for the settlement to use. -->
<column name="order_position" valueNumeric="99"/>
<column name="is_system_defined" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Voucher numbers move from a flat global sequence ("000001") to a dated, per-branch,
per-direction format: CR/001/2108/0001 (deposit) and DR/001/2108/0001 (withdrawal).
The counter restarts at 0001 every day for each (prefix, branch, date) combination,
so the old VARCHAR(6) columns no longer fit — widen every column the register
stamps its number onto. -->
<changeSet id="0484_widen_voucher_no_m_voucher" author="CBS">
<modifyDataType tableName="m_voucher" columnName="voucher_no" newDataType="VARCHAR(30)"/>
</changeSet>
<changeSet id="0484_widen_voucher_no_clerk_withdrawal" author="CBS">
<modifyDataType tableName="m_clerk_withdrawal_request" columnName="voucher_no" newDataType="VARCHAR(30)"/>
</changeSet>
<changeSet id="0484_widen_voucher_no_cashier_deposit" author="CBS">
<modifyDataType tableName="m_cashier_deposit_request" columnName="voucher_no" newDataType="VARCHAR(30)"/>
</changeSet>
<changeSet id="0484_widen_voucher_no_savings_txn" author="CBS">
<modifyDataType tableName="m_savings_account_transaction" columnName="voucher_no" newDataType="VARCHAR(30)"/>
</changeSet>
<changeSet id="0484_widen_voucher_no_clerk_disbursement" author="CBS">
<modifyDataType tableName="m_clerk_disbursement_request" columnName="voucher_no" newDataType="VARCHAR(30)"/>
</changeSet>
<!-- Per-(prefix, branch, date) counter. One row per series; the counter is bumped under a
row lock (SELECT ... FOR UPDATE) so two cashiers submitting at the same moment cannot
be handed the same number. The old scheme leaned on the m_voucher autoincrement PK for
uniqueness, which a daily-reset counter cannot do. -->
<changeSet id="0484_create_voucher_sequence" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_sequence"/></not>
</preConditions>
<createTable tableName="m_voucher_sequence">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="prefix" type="VARCHAR(4)">
<constraints nullable="false"/>
</column>
<column name="branch_code" type="VARCHAR(6)">
<constraints nullable="false"/>
</column>
<column name="series_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="last_number" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="m_voucher_sequence"
columnNames="prefix, branch_code, series_date"
constraintName="uq_voucher_sequence_series"/>
</changeSet>
<!-- Clear out the old flat-sequence vouchers ("000001"). They carry no branch and no date,
so they cannot be converted to the new format, and leaving them alongside CR/DR numbers
makes the register ambiguous to read. The m_voucher rows go, and every column that was
stamped from them is blanked so nothing points at a voucher that no longer exists.
Only the 6-digit legacy shape is touched — anything already in the new slash format is
left alone, which keeps this changeset safe to re-run. -->
<changeSet id="0484_purge_legacy_vouchers" author="CBS">
<sql>UPDATE m_savings_account_transaction SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$'</sql>
<sql>UPDATE m_clerk_withdrawal_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$'</sql>
<sql>UPDATE m_cashier_deposit_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$'</sql>
<sql>UPDATE m_clerk_disbursement_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$'</sql>
<sql>UPDATE m_share_advance_collection SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$'</sql>
<sql>DELETE FROM m_voucher WHERE voucher_no ~ '^[0-9]{6}$'</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Flat share-account charges were created with amount and amount_outstanding_derived both
forced to zero, while the configured fee sat only in charge_amount_or_percentage. Every consumer
that reads amount/amount_outstanding_derived therefore saw no entry fee at all: the share
collection settlement asked the member for the share value alone ("shares 400.00 +
charges 0"), and the fee was never collected.
ShareAccountCharge.populateDerivedFields() now populates both for FLAT charges. This
backfills the rows already written under the old behaviour.
Scoped deliberately narrowly — only flat charges (charge_calculation = 1) that still
carry a zero amount while amount_or_percentage holds a real fee, and only where nothing
has been paid, waived or written off. A charge that was genuinely settled, waived or
partly paid is left exactly as it is, so this cannot resurrect a closed fee. -->
<changeSet id="0485_backfill_flat_share_charge_amounts" author="CBS">
<sql>
UPDATE m_share_account_charge
SET amount = charge_amount_or_percentage,
amount_outstanding_derived = charge_amount_or_percentage,
is_paid_derived = false
WHERE charge_calculation_enum = 1
AND COALESCE(amount, 0) = 0
AND COALESCE(charge_amount_or_percentage, 0) &gt; 0
AND COALESCE(amount_paid_derived, 0) = 0
AND COALESCE(amount_waived_derived, 0) = 0
AND COALESCE(amount_writtenoff_derived, 0) = 0
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Share-account entry fees were marked fully paid the moment the account was activated,
before any money had been received. amount_paid_derived was set to the whole fee and
amount_outstanding_derived to zero, so both settlement routes — the cash receipt in
ShareAdvanceCollectionApiResource and the savings debit in ShareSavingsDebitRequestApiResource,
which each read amount_outstanding_derived — saw nothing owing and asked the member for the
share value alone ("shares 400.00 + charges 0"). The fee was never actually collected.
ShareAccountDataSerializer.handlechargesOnActivation() no longer marks the fee paid; it is
marked paid only where the money genuinely arrives. This resets the accounts already
activated under the old behaviour so their fee shows as outstanding again and the next
settlement asks for the correct total.
Scoped to activation-time charges (charge_time_enum = 13) that were never really settled:
nothing waived, nothing written off, and the account not yet settled by a collection. A fee
whose account has already had its collection applied is left untouched, so this cannot
re-open a genuinely paid fee or double-charge a member. -->
<changeSet id="0486_unpay_unsettled_share_activation_charges" author="CBS">
<sql>
UPDATE m_share_account_charge c
SET amount_paid_derived = 0,
amount_outstanding_derived = c.amount,
is_paid_derived = false
WHERE c.charge_time_enum = 13
AND c.is_active = true
AND c.is_paid_derived = true
AND COALESCE(c.amount, 0) &gt; 0
AND COALESCE(c.amount_waived_derived, 0) = 0
AND COALESCE(c.amount_writtenoff_derived, 0) = 0
AND EXISTS (
SELECT 1 FROM m_share_account sa
WHERE sa.id = c.account_id
AND COALESCE(sa.collection_applied, false) = false
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Physical verification of a voucher against the paper slip the member signed.
Someone at the counter reads the slip, compares it with what the system recorded, and
records the outcome: VERIFIED when the two agree, or EXCEPTION when they do not — for
example the slip says the member handed over 500 but the voucher was raised for a
different amount. An exception carries the amount actually written on the slip alongside
the reason, so whoever investigates can see both figures without hunting for the paper.
Keyed on voucher_no rather than a request id: one row here has to be able to describe a
deposit, a withdrawal or a disbursement voucher, and voucher_no is the only identifier all
three registers share. Unique on voucher_no so a voucher carries exactly one verdict —
re-verifying updates the existing row rather than stacking a second opinion beside it. -->
<changeSet id="0487_create_voucher_verification" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_verification"/></not>
</preConditions>
<createTable tableName="m_voucher_verification">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="voucher_no" type="VARCHAR(30)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_voucher_verification_voucher"/>
</column>
<!-- VERIFIED | EXCEPTION -->
<column name="status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!-- What the system recorded, captured at verification time so the comparison stays
readable even if the underlying request is later edited. -->
<column name="system_amount" type="DECIMAL(19,6)"/>
<!-- What the paper slip actually says. Null on a clean verification. -->
<column name="slip_amount" type="DECIMAL(19,6)"/>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="verified_by" type="BIGINT"/>
<column name="verified_by_name" type="VARCHAR(200)"/>
<column name="verified_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_voucher_verification" indexName="idx_voucher_verification_status">
<column name="status"/>
</createIndex>
</changeSet>
<!-- Files attached to a single voucher: the scanned slip, a cheque image, an ID proof.
Stored as rows here rather than in m_document because m_document's parent_entity_id is an
integer FK-shaped column, while a voucher is identified by its formatted number. -->
<changeSet id="0487_create_voucher_attachment" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_attachment"/></not>
</preConditions>
<createTable tableName="m_voucher_attachment">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="voucher_no" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="file_name" type="VARCHAR(250)">
<constraints nullable="false"/>
</column>
<column name="content_type" type="VARCHAR(100)"/>
<column name="file_size" type="BIGINT"/>
<column name="description" type="VARCHAR(500)"/>
<!-- Held in the database so an attachment cannot be separated from its voucher by a
filesystem move; the files involved are single scanned slips, not bulk media. -->
<column name="file_data" type="BLOB"/>
<column name="uploaded_by" type="BIGINT"/>
<column name="uploaded_by_name" type="VARCHAR(200)"/>
<column name="uploaded_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_voucher_attachment" indexName="idx_voucher_attachment_voucher">
<column name="voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- An exception raised at the counter has to reach someone who can act on it, and that person
has to be able to close it. These columns record the second half of that conversation:
who looked into it, what they concluded, and when — kept alongside the original flag rather
than replacing it, so the finished record shows both what the cashier found and what the
manager decided.
resolution_status is null while the exception is open, which is what the manager's queue
filters on. -->
<changeSet id="0488_add_voucher_exception_resolution" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_voucher_verification" columnName="resolution_status"/></not>
</preConditions>
<addColumn tableName="m_voucher_verification">
<!-- RESOLVED_SYSTEM_ERROR | RESOLVED_SLIP_ERROR | RESOLVED_NO_ACTION -->
<column name="resolution_status" type="VARCHAR(30)"/>
<column name="resolution_notes" type="VARCHAR(1000)"/>
<column name="resolved_by" type="BIGINT"/>
<column name="resolved_by_name" type="VARCHAR(200)"/>
<column name="resolved_at" type="TIMESTAMP"/>
</addColumn>
<createIndex tableName="m_voucher_verification" indexName="idx_voucher_verification_resolution">
<column name="resolution_status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Branch day register: the formal opening and closing of a branch's business day.
Until now only a cashier's own settlement was tracked; nothing recorded whether the branch
as a whole had opened or closed, so any past date stayed silently writable.
One row per (office, business date). closed_at null means the day is still open — which is
what the missed-close list looks for. A day closed long after the fact is marked
force_closed with a reason, because "closed nine days late" and "closed that evening" are
not the same fact and an audit has to be able to tell them apart. -->
<changeSet id="0489_create_branch_day_register" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_branch_day_register"/></not>
</preConditions>
<createTable tableName="m_branch_day_register">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="business_date" type="DATE">
<constraints nullable="false"/>
</column>
<!-- Opening -->
<column name="opened_by" type="BIGINT"/>
<column name="opened_by_name" type="VARCHAR(200)"/>
<column name="opened_at" type="TIMESTAMP"/>
<column name="opening_cash" type="DECIMAL(19,6)"/>
<!-- Closing. Null while the day is still open. -->
<column name="closed_by" type="BIGINT"/>
<column name="closed_by_name" type="VARCHAR(200)"/>
<column name="closed_at" type="TIMESTAMP"/>
<column name="closing_cash" type="DECIMAL(19,6)"/>
<!-- Book position at close, so a difference can be read back later without
recomputing it from transactions that may since have been reversed. -->
<column name="book_cash" type="DECIMAL(19,6)"/>
<column name="cash_difference" type="DECIMAL(19,6)"/>
<!-- A day closed after its own date, with the reason it was left open. -->
<column name="force_closed" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="force_close_reason" type="VARCHAR(1000)"/>
<column name="remarks" type="VARCHAR(1000)"/>
</createTable>
<!-- A branch cannot open the same business date twice. -->
<addUniqueConstraint tableName="m_branch_day_register"
columnNames="office_id, business_date"
constraintName="uq_branch_day_office_date"/>
<createIndex tableName="m_branch_day_register" indexName="idx_branch_day_open">
<column name="closed_at"/>
</createIndex>
</changeSet>
<!-- Menu permission for the new Branch Operations section. -->
<changeSet id="0489_seed_branch_ops_menu_permission" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_BRANCH_OPS'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="heading_menu"/>
<column name="code" value="MENU_BRANCH_OPS"/>
<column name="entity_name" value="MENU"/>
<column name="action_name" value="BRANCH_OPS"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Granted to the roles that actually run a branch day. The clerk is included alongside the
manager deliberately: at this bank both carry the same authority over the day, and a day
left open because the only manager was away is exactly the problem this register exists to
prevent. A cashier is not granted it — the cashier works inside the day, and must not be
the one who declares it closed. -->
<changeSet id="0489_grant_branch_ops_menu" author="CBS">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE p.code = 'MENU_BRANCH_OPS'
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) LIKE '%clerk%'
OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,76 @@
<?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">
<!--
Splits retained profit into two equity accounts, so a year's profit can only ever be
distributed once.
BANKPROFITND01 Bank Profit (Non-Distributable) the year's profit; dividends come out of here
BANKPROFIT01 Bank Profit what is left afterwards; never distributed again
Year-end closing posts the year's income and expenses into the Non-Distributable account,
and a dividend is declared against that balance. When the declaration is approved, whatever
the members did not receive — the undistributed remainder, plus the paise rounding shaved
off each entitlement — moves across to Bank Profit.
Without the split both figures share one account, and a dividend declared for a later year
would be sized on a balance that still carries earlier years' leftovers: profit already
appropriated once, offered for distribution a second time. The rounding residue is the
clearest case — it belongs to the year that was declared, but under a single account it
would sit in the pool the next declaration measures.
The naming reads backwards at first glance, and is deliberate: "Non-Distributable" marks
profit that has not been through a distribution decision yet, and plain "Bank Profit" is
where it settles once that decision is made and closed off.
-->
<changeSet id="0490_seed_bank_profit_non_distributable_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01'
</sqlCheck>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Bank Profit (Non-Distributable)"/>
<column name="gl_code" value="BANKPROFITND01"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="3"/>
<column name="description" value="The financial year's profit, closed here at year end. Dividends are declared against this balance; whatever is not distributed moves to Bank Profit once the declaration is approved."/>
</insert>
</changeSet>
<!--
Bank Profit's own description no longer matches what it holds: income is closed into the
Non-Distributable account now, and this receives only what a declaration leaves behind.
-->
<changeSet id="0490_restate_bank_profit_description" author="CBS">
<update tableName="acc_gl_account">
<column name="description" value="Profit that has already been through a dividend decision: the undistributed remainder and rounding residue moved here when a declaration was approved. Never enters a later dividend calculation."/>
<where>gl_code = 'BANKPROFIT01'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
@OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<InterestRateChartSlab> chartSlabs = new HashSet<>();
/**
* Whether this is a rate version the product currently offers.
* <p>
* Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate
* act and the dates stay purely the historical record. New charts start active.
*/
@Column(name = "is_active", nullable = false)
private boolean active = true;
public boolean isActive() {
return this.active;
}
/** Retires this rate version, so it stops being picked for new and renewing deposits. */
public void deactivate() {
this.active = false;
}
public void activate() {
this.active = true;
}
protected InterestRateChart() {
//
}
@@ -123,6 +123,10 @@ public class InterestRateChartFields {
return compare != null && DateUtils.isAfter(getFromDate(), compare);
}
public String getName() {
return this.name;
}
public LocalDate getFromDate() {
return this.fromDate;
}
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
return isGreater;
}
public String description() {
return this.description;
}
public Integer periodType() {
return this.periodType;
}
@@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData {
private final BigDecimal annualInterestRate;
private final CurrencyData currency;
/**
* The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the
* account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced
* off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab.
*/
private final BigDecimal seniorCitizenRate;
// associations
private Collection<DepositAccountInterestIncentiveData> incentives;
@@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData {
public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType,
final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo,
final BigDecimal annualInterestRate, final CurrencyData currency) {
final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) {
final Collection<EnumOptionData> periodTypes = null;
final Collection<EnumOptionData> entityTypeOptions = null;
final Collection<EnumOptionData> attributeNameOptions = null;
@@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData {
final Collection<DepositAccountInterestIncentiveData> incentives = null;
return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions,
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions);
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions,
seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) {
@@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData {
fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive));
}
}
// the product-level chart this comes from has no senior-citizen concept at all
final BigDecimal seniorCitizenRate = null;
return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(),
chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(),
chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(),
chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(),
chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(),
chartSlabData.clientClassificationOptions());
chartSlabData.clientClassificationOptions(), seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab,
@@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData {
return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod,
chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives,
chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions,
genderOptions, clientTypeOptions, clientClassificationOptions);
genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate);
}
private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType,
@@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData {
final CurrencyData currency, final Collection<EnumOptionData> periodTypes, final Collection<EnumOptionData> entityTypeOptions,
final Collection<EnumOptionData> attributeNameOptions, final Collection<EnumOptionData> conditionTypeOptions,
final Collection<EnumOptionData> incentiveTypeOptions, final Collection<CodeValueData> genderOptions,
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions) {
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions,
final BigDecimal seniorCitizenRate) {
this.id = id;
this.description = description;
this.periodType = periodType;
@@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData {
this.amountRangeTo = amountRangeTo;
this.annualInterestRate = annualInterestRate;
this.currency = currency;
this.seniorCitizenRate = seniorCitizenRate;
this.periodTypes = periodTypes;
this.incentives = incentivesData;
this.attributeNameOptions = attributeNameOptions;
@@ -138,6 +138,50 @@ public final class FixedDepositAccountData extends DepositAccountData {
private Boolean isSeniorCitizen;
private java.math.BigDecimal seniorCitizenInterestRate;
// Reinvestment trail: which account this one was reinvested FROM (set on the new account) and
// which account it was reinvested INTO (derived by reverse lookup on the old account), so staff
// can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions
// instead of the link disappearing once the old account closes.
private Long reinvestedFromAccountId;
private String reinvestedFromAccountNo;
private Long reinvestedIntoAccountId;
private String reinvestedIntoAccountNo;
public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo,
final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) {
this.reinvestedFromAccountId = reinvestedFromAccountId;
this.reinvestedFromAccountNo = reinvestedFromAccountNo;
this.reinvestedIntoAccountId = reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
}
// The account number the customer should keep seeing across renewals -- the ORIGINAL account's
// account_no, carried forward onto every account created by reinvesting it. Falls back to this
// account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl).
private String fdAccountNumber;
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
/** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */
private Integer renewalCycleNo;
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
/**
* Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own
* account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit
* certificate present "one FD, many terms" without the caller having to walk the chain itself.
*/
private java.util.List<java.util.Map<String, Object>> renewalHistory;
public void setRenewalHistory(final java.util.List<java.util.Map<String, Object>> renewalHistory) {
this.renewalHistory = renewalHistory;
}
// CBS account-level fields (stored per-account in m_savings_account)
private String accountStatus;
private String modeOfOperation;
@@ -329,6 +373,13 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.jointHolders = src.jointHolders;
this.isSeniorCitizen = src.isSeniorCitizen;
this.seniorCitizenInterestRate = src.seniorCitizenInterestRate;
this.reinvestedFromAccountId = src.reinvestedFromAccountId;
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
this.fdAccountNumber = src.fdAccountNumber;
this.renewalCycleNo = src.renewalCycleNo;
this.renewalHistory = src.renewalHistory;
this.subStatus = src.subStatus;
this.reasonForBlock = src.reasonForBlock;
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
@@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory;
@@ -156,4 +160,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
public boolean isPrimaryGroupingByAmount() {
return this.chartFields.isPrimaryGroupingByAmount();
}
/**
* Re-snapshots this account's chart from the product's current chart, in place.
* <p>
* Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the
* renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather
* than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so
* a second row for the same account would break loading it.
*/
public void refreshFrom(final InterestRateChart productChart) {
this.chartFields = productChart.chartFields();
// orphanRemoval on chartSlabs deletes the superseded slab rows.
setOfChartSlabs().clear();
for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) {
this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this));
}
}
/**
* A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together.
* <p>
* The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair
* rather than being added to the shared type.
*/
public static final class SlabSnapshot {
private final InterestRateChartSlabFields fields;
private final BigDecimal seniorCitizenRate;
public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) {
this.fields = fields;
this.seniorCitizenRate = seniorCitizenRate;
}
public InterestRateChartSlabFields getFields() {
return this.fields;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
}
/**
* This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before
* {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than
* losing its rate card the moment the new term's rates replace it.
*/
public List<SlabSnapshot> currentSlabSnapshots() {
final List<SlabSnapshot> snapshots = new ArrayList<>();
for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) {
snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place.
* <p>
* Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that
* master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would
* disagree with what the bank publishes. Mutates the existing row for the same reason
* {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's
* savings_account_id, so a second row for the same account would break loading it.
* <p>
* Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate
* version can never leave a deposit with no rates at all.
*/
public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection<SlabSnapshot> snapshots) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
if (chartFields != null) {
this.chartFields = chartFields;
}
setOfChartSlabs().clear();
for (final SlabSnapshot snapshot : snapshots) {
this.chartSlabs
.add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate()));
}
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
@OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<DepositAccountInterestIncentives> interestIncentives = new HashSet<>();
/**
* The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the
* time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its
* own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries
* one) or for a slab that predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
protected DepositAccountInterestRateChartSlabs() {
//
}
@@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
this.interestIncentives = interestIncentives;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) {
this.depositAccountInterestRateChart = depositAccountInterestRateChart;
}
@@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
return this.slabFields;
}
/**
* Builds a slab straight from its field values, for a chart taken from a source that is not a product
* {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate
* card and carries no incentive rules.
*/
public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields,
final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) {
final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields,
depositAccountInterestRateChart, new HashSet<>());
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab,
DepositAccountInterestRateChart depositAccountInterestRateChart) {
InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields();
@@ -89,6 +89,49 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
@Column(name = "transfer_to_savings_account_id")
private Long transferToSavingsAccountId;
// Set on the NEW account created by a "Re-Invest" maturity closure, pointing back at the account
// it was reinvested from. There is no separate "reinvested into" column on the OLD account -- that
// direction is derived by looking up whichever row has this account's id here (see
// DepositAccountReadPlatformServiceImpl), so the relationship needs tracking in only one place.
@Column(name = "reinvested_from_savings_id")
private Long reinvestedFromSavingsId;
public Long getReinvestedFromSavingsId() {
return this.reinvestedFromSavingsId;
}
public void setReinvestedFromSavingsId(final Long reinvestedFromSavingsId) {
this.reinvestedFromSavingsId = reinvestedFromSavingsId;
}
// The account_no of the FIRST account in a renewal chain, carried forward onto every account
// created by reinvesting it. Null on an account that has never been reinvested -- callers should
// fall back to that account's own account_no in that case (see FixedDepositAccount.reInvest()).
@Column(name = "fd_account_number", length = 20)
private String fdAccountNumber;
public String getFdAccountNumber() {
return this.fdAccountNumber;
}
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
// a chain of per-term account rows.
@Column(name = "renewal_cycle_no")
private Integer renewalCycleNo;
public Integer getRenewalCycleNo() {
return this.renewalCycleNo == null ? 0 : this.renewalCycleNo;
}
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
protected DepositAccountTermAndPreClosure() {
}
@@ -297,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.onAccountClosureType = onClosureType.getValue();
}
/**
* Copies the standing instructions -- maturity instruction, its payout target and the interest-transfer flag -- from
* the term being renewed.
* <p>
* These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term,
* so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again.
*/
public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) {
this.onAccountClosureType = previousTerm.onAccountClosureType;
this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId;
this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount;
}
public boolean isReinvestOnClosure() {
return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest();
}
@@ -317,6 +373,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
final LocalDate expectedFirstDepositOnDate = null;
final Boolean transferInterestToLinkedAccount = false;
// Left blank here on purpose: this method is shared with Recurring Deposit renewal, so carrying
// standing instructions forward is done by the caller that wants it (see
// FixedDepositAccount#reInvest) rather than changed for both product types at once.
final DepositAccountOnClosureType accountOnClosureType = null;
final Long transferToSavingsId = null;
return DepositAccountTermAndPreClosure.createNew(preClosureDetail, depositTermDetail, account, actualDepositAmount, maturityAmount,
@@ -324,6 +324,42 @@ public class FixedDepositProduct extends SavingsProduct {
return applicableChart;
}
/**
* The rate chart the product currently offers for a deposit priced on the given date: a chart flagged active, that has
* come into force by that date and has not been superseded. Where more than one qualifies, the most recently effective
* wins.
* <p>
* Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the
* active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a
* deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered
* set, so two charts covering the same date resolve arbitrarily.
* <p>
* Returns null when the product offers no active chart in force on that date.
*/
public InterestRateChart activeChart(final LocalDate target) {
if (this.charts == null || target == null) {
return null;
}
InterestRateChart activeChart = null;
for (final InterestRateChart chart : this.charts) {
if (!chart.isActive()) {
continue; // retired rate version
}
final LocalDate fromDate = chart.getFromDate();
final LocalDate endDate = chart.getEndDate();
if (fromDate == null || fromDate.isAfter(target)) {
continue; // not yet in force
}
if (endDate != null && endDate.isBefore(target)) {
continue; // already superseded
}
if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) {
activeChart = chart;
}
}
return activeChart;
}
public DepositProductTermAndPreClosure depositProductTermAndPreClosure() {
return this.productTermAndPreClosure;
}

Some files were not shown because too many files have changed in this diff Show More