feat: implement account register, branch day, voucher verification, and share dividend backend APIs
This commit is contained in:
+185
@@ -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);
|
||||
}
|
||||
}
|
||||
+278
@@ -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());
|
||||
}
|
||||
}
|
||||
+180
@@ -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;
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+377
@@ -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;
|
||||
}
|
||||
}
|
||||
+299
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+477
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -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) {
|
||||
|
||||
+754
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -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,
|
||||
|
||||
+601
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
+303
-460
@@ -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);
|
||||
|
||||
+75
-49
@@ -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);
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
+7
-2
@@ -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())) {
|
||||
|
||||
+10
-6
@@ -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 {
|
||||
|
||||
+15
-8
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+60
-18
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+528
-522
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -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
|
||||
|
||||
+579
@@ -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());
|
||||
}
|
||||
}
|
||||
+33
@@ -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 })
|
||||
|
||||
+262
@@ -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());
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -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) {
|
||||
|
||||
+536
-50
@@ -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");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
+429
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
-16
@@ -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>
|
||||
|
||||
+33
@@ -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>
|
||||
+31
@@ -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>
|
||||
+88
@@ -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>
|
||||
+43
@@ -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>
|
||||
+42
@@ -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>
|
||||
+36
@@ -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>
|
||||
+89
@@ -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>
|
||||
+50
@@ -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>
|
||||
+116
@@ -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>
|
||||
+65
@@ -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>
|
||||
+146
@@ -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>
|
||||
+68
@@ -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>
|
||||
+120
@@ -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>
|
||||
+54
@@ -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>
|
||||
+78
@@ -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>
|
||||
+36
@@ -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) > 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>
|
||||
+44
@@ -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) > 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>
|
||||
+81
@@ -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>
|
||||
+33
@@ -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>
|
||||
+100
@@ -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>
|
||||
+76
@@ -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>
|
||||
Reference in New Issue
Block a user