feat: implement account register, branch day, voucher verification, and share dividend backend APIs

This commit is contained in:
2026-08-25 11:31:49 +05:30
parent 9eac40bb5c
commit 73ec85cfd6
60 changed files with 8075 additions and 1148 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); //
@@ -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"), //
@@ -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;
}
}
@@ -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);
@@ -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,25 @@
<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"/>
</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,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,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,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,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,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,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>