feat(shares): real-profit driven dividends with GL-only liability model and member claim workflow

Reworks the share dividend lifecycle end to end:
- Dividend amount now auto-computed from real GL profit x officer-entered percentage
  instead of manual entry; adds a Dividend Paid report over the existing Dividend
  Payable ledger.
- Declaration posts a real GL entry (Dr Reserve for Dividend / Cr Dividend Payable);
  approval no longer credits any savings account up front -- every member's
  entitlement stays a pure "Dividend Payable" liability until claimed, resolved
  synchronously at approval time (no background job or per-member failure mode).
- New maker-checker Dividend Claim Request workflow: a clerk records how a member
  wants their unclaimed dividend split (overdue loan / savings credit / cash), a
  manager approves, which executes native repayment/deposit commands and replenishes
  whichever GL account they actually debited (not a hardcoded assumption) from the
  Dividend Payable liability.
- Unclaimed dividends carry a 3-year window (m_share_dividend_lock) after which a
  daily job forfeits them straight to profit; a Dividend Dashboard consolidates
  summary stats, every declaration's status, full lock history, and proactive
  loan-application for members with overdue EMIs.
- Fixes a native negative-share-days bug in ShareProductDividendAssembler and adds
  overlap-period validation so a product can't have two declarations covering the
  same days.
- Removes the now-unreachable failed/pending-payout tracking that the old
  per-member job-based model needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:42:45 +05:30
co-authored by Claude Sonnet 5
parent 2213308254
commit 9dd4f8bedb
24 changed files with 2066 additions and 6 deletions
@@ -0,0 +1,131 @@
/**
* 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 com.google.gson.JsonArray;
import com.google.gson.JsonObject;
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.sql.Date;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
* is, by construction, a real dividend payment.
*/
@Path("/v1/accounting/dividend-paid-report")
@Component
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
@RequiredArgsConstructor
public class DividendPaidReportApiResource {
private final JdbcTemplate jdbcTemplate;
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 JOURNAL_ENTRY_TYPE_DEBIT = 2;
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
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");
}
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 JsonObject result = new JsonObject();
result.addProperty("fromDate", fromDate);
result.addProperty("toDate", toDate);
if (payableDividendsAccountId == null) {
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
result.addProperty("totalPaid", BigDecimal.ZERO);
result.addProperty("payoutCount", 0);
result.add("entries", new JsonArray());
result.addProperty("glAccountConfigured", false);
return gson.toJson(result);
}
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
SELECT entry_date, amount, transaction_id
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
ORDER BY entry_date DESC
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
result.addProperty("glAccountConfigured", true);
final JsonArray entries = new JsonArray();
for (final Map<String, Object> row : entryRows) {
final JsonObject entry = new JsonObject();
entry.addProperty("entryDate", row.get("entry_date").toString());
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("transactionId", (String) row.get("transaction_id"));
entries.add(entry);
}
result.add("entries", entries);
return gson.toJson(result);
}
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());
}
}
@@ -0,0 +1,98 @@
/**
* 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.sql.Date;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Real Profit &amp; Loss (Net Profit) for a date range, computed live from posted General Ledger
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
*
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
* and decreases with a CREDIT. Reversed entries are excluded.
*/
@Path("/v1/accounting/profitandloss")
@Component
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
@RequiredArgsConstructor
public class ProfitAndLossApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
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");
}
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");
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
"netProfit", netProfit));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,513 @@
/**
* 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.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
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.NotFoundException;
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.WebApplicationException;
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.time.LocalDate;
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.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;
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.
*
* <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, an
* immediate manual journal entry (Dr Dividend Payable / Cr whichever account was actually debited
* -- read straight off the GL entry the native command just posted) replenishes it for that same
* amount, so the net effect is exactly "Dividend Payable decreases, loan/savings changes, that
* fund-source account is untouched". The cash leg has no account to post through, so it's a direct
* manual entry only (Dr Dividend Payable / Cr Cash at Main Vault) -- real cash leaves the vault
* into the member's hand.
*/
@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.")
@RequiredArgsConstructor
public class DividendClaimRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
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;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
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", "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);
}
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
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));
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root));
if (lockId == null) {
throw apiError(400, "lockId is required");
}
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, savings_account_id, 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 not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
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");
}
}
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");
}
}
final Long existingPending = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
if (existingPending != null && existingPending > 0) {
throw apiError(409, "A claim request for this dividend is already pending review.");
}
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
private BigDecimal parseAmount(final String value) {
if (value == null || value.isBlank()) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "'" + value + "' is not a valid amount");
}
}
// ── GET /v1/clerk/dividend-claim-requests ── Manager sees claim requests ────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + """
FROM m_share_dividend_claim_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
JOIN m_share_dividend_lock l ON l.id = r.lock_id
WHERE r.status = ?
ORDER BY r.submitted_at ASC
""", status);
for (final Map<String, Object> row : rows) {
final Long clientId = ((Number) row.get("client_id")).longValue();
row.put("has_overdue_loan", hasOverdueLoan(clientId));
}
return gson.toJson(rows);
}
private boolean hasOverdueLoan(final Long clientId) {
final Long count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) 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
""", Long.class, clientId);
return count != null && count > 0;
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/approve ── Manager executes the disbursement ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
}
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.");
}
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));
if (!"UNCLAIMED".equals(lock.get("status"))) {
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
}
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) {
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 + ")";
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);
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
replenishFundSourceFromDividendPayable(id, currencyCode, loanApplyAmount, today, fundSourceAccountId);
}
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);
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
final Long fundSourceAccountId = resolveDebitAccountForSavingsTransaction(depositResult.getResourceId());
replenishFundSourceFromDividendPayable(id, currencyCode, savingsApplyAmount, today, fundSourceAccountId);
}
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
}
final BigDecimal remainder = unclaimedAmount.subtract(total);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
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);
}
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), id);
final JsonObject response = new JsonObject();
response.addProperty("resourceId", id);
response.addProperty("status", "APPROVED");
response.addProperty("totalDisbursed", total);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
/**
* Which GL account a loan repayment or savings deposit's cash/fund-source leg actually debits
* is a per-product (sometimes per-payment-type) configuration choice, not always literally
* "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its own
* "Warehouse Receipt" account instead. Rather than assume, these read the actual GL entry the
* native command just posted for this transaction and return whichever account it debited.
*/
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
private Long resolveDebitAccountForSavingsTransaction(final Long savingsTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ?",
Long.class, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
/**
* The loan repayment / savings deposit just posted above assumed the money came out of
* whichever account is that product's actual Fund Source -- it didn't really. This puts that
* fund straight back, sourced from the Dividend Payable liability instead, so the net effect
* is exactly "liability shrinks, the loan/savings account changes, that fund-source account is
* unaffected". Skipped (loan/savings still updated) if the bank hasn't mapped the Dividend
* Payable financial activity, or if the command's own debit leg couldn't be found.
*/
private void replenishFundSourceFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate, final Long fundSourceAccountId) {
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 || fundSourceAccountId == 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 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(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
/**
* 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) {
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 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(), ?, ?)
""";
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);
}
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING claim requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
jdbcTemplate.update("""
UPDATE m_share_dividend_claim_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, rejection_reason = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), reason, Timestamp.from(Instant.now()), id);
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
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 apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject a dividend claim request.");
}
}
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,
status, clerk_id, clerk_name
FROM m_share_dividend_claim_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Dividend claim request not found: " + id);
}
return rows.get(0);
}
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());
}
}
@@ -0,0 +1,430 @@
/**
* 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.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
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.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.sql.Timestamp;
import java.time.LocalDate;
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.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;
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.
*/
@Path("/v1/clerk/dividend-dashboard")
@Component
@Tag(name = "Dividend Dashboard", description = "Bank Manager view of locked share dividends belonging to members with overdue loan EMIs, with an action to apply the dividend towards the loan.")
@RequiredArgsConstructor
public class DividendDashboardApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
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 JOURNAL_ENTRY_TYPE_DEBIT = 2;
private static final int DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL = 100;
private static final int DIVIDEND_PAYOUT_STATUS_APPROVED = 300;
// ── GET /v1/clerk/dividend-dashboard/summary ─────────────────────────────────
@GET
@Path("/summary")
@Produces(MediaType.APPLICATION_JSON)
public String summary() {
final Map<String, Object> declarationStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) AS total_declarations,
COUNT(*) FILTER (WHERE status = %d) AS pending_approval_declarations,
COUNT(*) FILTER (WHERE status = %d) AS approved_declarations
FROM m_share_product_dividend_pay_out
""".formatted(DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL, DIVIDEND_PAYOUT_STATUS_APPROVED));
// Approval and disbursement now happen atomically (see
// ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable) -- there's no
// more "posted vs pending vs failed" spread to track at the detail level once approved.
final Map<String, Object> detailStats = jdbcTemplate.queryForMap("""
SELECT COUNT(DISTINCT sa.client_id) AS members_affected,
COUNT(sad.id) AS total_member_dividends,
COALESCE(SUM(sad.amount), 0) AS total_amount
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
""");
final Map<String, Object> lockStats = jdbcTemplate.queryForMap("""
SELECT COUNT(*) FILTER (WHERE status = 'UNCLAIMED') AS locked_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'UNCLAIMED'), 0) AS locked_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL) AS applied_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL), 0) AS applied_amount,
COUNT(*) FILTER (WHERE status = 'FORFEITED') AS forfeited_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'FORFEITED'), 0) AS forfeited_amount,
COUNT(*) FILTER (WHERE status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED'), 0) AS claimed_amount
FROM m_share_dividend_lock
""");
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("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());
result.addProperty("totalAmount", (BigDecimal) detailStats.get("total_amount"));
result.addProperty("lockedCount", ((Number) lockStats.get("locked_count")).longValue());
result.addProperty("lockedAmount", (BigDecimal) lockStats.get("locked_amount"));
result.addProperty("appliedToLoanCount", ((Number) lockStats.get("applied_count")).longValue());
result.addProperty("appliedToLoanAmount", (BigDecimal) lockStats.get("applied_amount"));
result.addProperty("forfeitedCount", ((Number) lockStats.get("forfeited_count")).longValue());
result.addProperty("forfeitedAmount", (BigDecimal) lockStats.get("forfeited_amount"));
result.addProperty("claimedCount", ((Number) lockStats.get("claimed_count")).longValue());
result.addProperty("claimedAmount", (BigDecimal) lockStats.get("claimed_amount"));
return gson.toJson(result);
}
// ── 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.
*/
@GET
@Path("/declarations")
@Produces(MediaType.APPLICATION_JSON)
public String declarations() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT spd.id AS declaration_id, sp.name AS product_name,
spd.dividend_period_start_date, spd.dividend_period_end_date, spd.amount AS declared_amount,
spd.status AS declaration_status, spd.created_date, spd.lastmodified_date AS approved_on,
COUNT(sad.id) AS total_members,
COALESCE(SUM(sad.amount), 0) AS total_distributed_amount
FROM m_share_product_dividend_pay_out spd
JOIN m_share_product sp ON sp.id = spd.product_id
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = spd.id
GROUP BY spd.id, sp.name, spd.dividend_period_start_date, spd.dividend_period_end_date,
spd.amount, spd.status, spd.created_date, spd.lastmodified_date
ORDER BY spd.created_date DESC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final int declarationStatus = ((Number) row.get("declaration_status")).intValue();
final Timestamp approvedOn = (Timestamp) row.get("approved_on");
final boolean isApproved = declarationStatus == DIVIDEND_PAYOUT_STATUS_APPROVED;
final JsonObject entry = new JsonObject();
entry.addProperty("declarationId", ((Number) row.get("declaration_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("periodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("periodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredAmount", (BigDecimal) row.get("declared_amount"));
entry.addProperty("totalDistributedAmount", (BigDecimal) row.get("total_distributed_amount"));
entry.addProperty("createdDate", row.get("created_date").toString());
entry.addProperty("approvedOn", approvedOn == null ? null : approvedOn.toString());
entry.addProperty("totalMembers", ((Number) row.get("total_members")).longValue());
entry.addProperty("status", isApproved ? "APPROVED" : "PENDING_APPROVAL");
result.add(entry);
}
return gson.toJson(result);
}
// ── 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.
*/
@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);
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 " //
+ "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 " //
+ (filterByStatus ? "WHERE l.status = ? " : "") //
+ "ORDER BY l.locked_date DESC";
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
: jdbcTemplate.queryForList(sql);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
entry.addProperty("clientName", (String) row.get("client_name"));
final Object savingsAccountId = row.get("savings_account_id");
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("lockedDate", row.get("locked_date").toString());
entry.addProperty("releaseDueDate", row.get("release_due_date").toString());
entry.addProperty("status", (String) row.get("status"));
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"));
result.add(entry);
}
return gson.toJson(result);
}
// ── GET /v1/clerk/dividend-dashboard ─────────────────────────────────────────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
l.locked_date, l.release_due_date
FROM m_share_dividend_lock l
JOIN m_client c ON c.id = l.client_id
WHERE l.status = 'UNCLAIMED'
AND EXISTS (
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
WHERE ln.client_id = l.client_id AND ln.loan_status_id = 300
AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
)
ORDER BY l.locked_date ASC
""");
final JsonArray result = new JsonArray();
for (final Map<String, Object> lock : locks) {
final Long clientId = ((Number) lock.get("client_id")).longValue();
final JsonObject row = new JsonObject();
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
row.addProperty("clientId", clientId);
row.addProperty("clientName", (String) lock.get("client_name"));
final Object savingsAccountId = lock.get("savings_account_id");
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
row.addProperty("lockedDate", lock.get("locked_date").toString());
row.addProperty("releaseDueDate", lock.get("release_due_date").toString());
row.add("overdueLoans", overdueLoansFor(clientId));
result.add(row);
}
return gson.toJson(result);
}
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 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 result;
}
// ── 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 replenishes the vault it assumed the cash came from, sourced
* instead from the Dividend Payable liability -- identical mechanic to a claim's loan leg.
*/
@POST
@Path("/{lockId}/apply-to-loan")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String applyToLoan(@PathParam("lockId") final Long lockId, final String body) {
final Map<String, Object> lock = jdbcTemplate
.queryForList("SELECT client_id, 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 not currently UNCLAIMED (status: " + lock.get("status") + ")");
}
final Long clientId = ((Number) lock.get("client_id")).longValue();
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
final JsonElement root = fromJsonHelper.parse(body);
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
final String amountString = fromJsonHelper.extractStringNamed("amount", root);
if (loanId == null) {
throw apiError(400, "loanId is required");
}
BigDecimal applyAmount;
try {
applyAmount = amountString == null ? null : new BigDecimal(amountString.trim());
} catch (final NumberFormatException e) {
throw apiError(400, "amount must be a valid number");
}
if (applyAmount == null || applyAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "amount must be greater than zero");
}
if (applyAmount.compareTo(unclaimedAmount) > 0) {
throw apiError(400, "amount cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
}
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");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
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 CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
replenishFundSourceFromDividendPayable(lockId, currencyCode, applyAmount, today, fundSourceAccountId);
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);
} else {
jdbcTemplate.update("""
UPDATE m_share_dividend_lock
SET status = 'CLAIMED', applied_loan_id = ?, applied_transaction_id = ?, last_modified_date = now()
WHERE id = ?
""", loanId, repaymentResult.getResourceId(), lockId);
}
final JsonObject response = new JsonObject();
response.addProperty("lockId", lockId);
response.addProperty("appliedAmount", applyAmount);
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
return gson.toJson(response);
}
/**
* Which GL account a loan repayment's cash/fund-source leg actually debits is a per-product
* (sometimes per-payment-type) configuration choice, not always literally "Cash at Main Vault"
* -- e.g. a Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account
* instead. Rather than assume, this reads the actual GL entry the native repayment command
* just posted for this transaction and returns whichever account it debited.
*/
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
return jdbcTemplate
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
.stream().findFirst().orElse(null);
}
/**
* The loan repayment just posted above assumed the money came out of whichever account is
* this loan product's actual Fund Source -- it didn't really. This puts that fund straight
* back, sourced from the Dividend Payable liability instead, so the net effect is exactly
* "liability shrinks, loan reduces, that fund-source account is unaffected". Skipped (loan
* still updated) if the bank hasn't mapped the Dividend Payable financial activity, or if the
* repayment's own debit leg couldn't be found.
*/
private void replenishFundSourceFromDividendPayable(final Long lockId, final String currencyCode, final BigDecimal amount,
final LocalDate entryDate, final Long fundSourceAccountId) {
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 || fundSourceAccountId == null) {
return;
}
final AppUser currentUser = context.authenticatedUser();
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
final String transactionId = "DIVAPPLYLOAN" + lockId + "-" + System.nanoTime();
final String description = "Unclaimed dividend lock #" + lockId + " applied to loan";
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(), ?, ?)
""";
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
}
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());
}
}
@@ -0,0 +1,86 @@
/**
* 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.dividenddashboard.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
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.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every Share Product's dividend declaration that is still awaiting approval — Fineract only
* exposes dividends scoped to one product at a time
* ({@code GET /shareproduct/{productId}/dividend}), so a Bank Manager has no single place to see
* "everything currently waiting on me" across products. This aggregates all
* {@code m_share_product_dividend_pay_out} rows still INITIATED so they can be surfaced on the
* Approval Center (Checker Inbox) alongside the other maker-checker request types. Approval
* itself still goes through Fineract's native {@code approveDividend} command from the
* frontend — this resource only covers the missing cross-product listing.
*/
@Path("/v1/clerk/pending-dividends")
@Component
@Tag(name = "Pending Dividends", description = "Dividend declarations awaiting approval, across all Share Products.")
@RequiredArgsConstructor
public class PendingDividendsApiResource {
private static final int DIVIDEND_PAYOUT_STATUS_INITIATED = 100;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT p.id AS dividend_id, p.product_id, sp.name AS product_name, p.amount,
p.dividend_period_start_date, p.dividend_period_end_date, p.created_date,
(SELECT COUNT(*) FROM m_share_account_dividend_details d WHERE d.dividend_pay_out_id = p.id) AS member_count
FROM m_share_product_dividend_pay_out p
JOIN m_share_product sp ON sp.id = p.product_id
WHERE p.status = ?
ORDER BY p.created_date ASC
""", DIVIDEND_PAYOUT_STATUS_INITIATED);
final JsonArray result = new JsonArray();
for (final Map<String, Object> row : rows) {
final JsonObject entry = new JsonObject();
entry.addProperty("dividendId", ((Number) row.get("dividend_id")).longValue());
entry.addProperty("productId", ((Number) row.get("product_id")).longValue());
entry.addProperty("productName", (String) row.get("product_name"));
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("dividendPeriodStartDate", row.get("dividend_period_start_date").toString());
entry.addProperty("dividendPeriodEndDate", row.get("dividend_period_end_date").toString());
entry.addProperty("declaredOn", row.get("created_date") == null ? null : row.get("created_date").toString());
entry.addProperty("memberCount", ((Number) row.get("member_count")).intValue());
result.add(entry);
}
return gson.toJson(result);
}
}
@@ -0,0 +1,63 @@
/**
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ForfeitUnclaimedDividendsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ShareAccountSchedularService shareAccountSchedularService;
@Bean
protected Step forfeitUnclaimedDividendsStep() {
return new StepBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository)
.tasklet(forfeitUnclaimedDividendsTasklet(), transactionManager).build();
}
@Bean
public Job forfeitUnclaimedDividendsJob() {
return new JobBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository).start(forfeitUnclaimedDividendsStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public ForfeitUnclaimedDividendsTasklet forfeitUnclaimedDividendsTasklet() {
return new ForfeitUnclaimedDividendsTasklet(jdbcTemplate, shareAccountSchedularService);
}
}
@@ -0,0 +1,67 @@
/**
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
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;
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.
*/
@Slf4j
@RequiredArgsConstructor
public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private final ShareAccountSchedularService shareAccountSchedularService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final List<Throwable> exceptions = new ArrayList<>();
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);
for (final Long lockId : lockIds) {
try {
this.shareAccountSchedularService.forfeitDividendLock(lockId);
} catch (final Exception e) {
log.error("Forfeit Unclaimed Dividends failed for share dividend lock id: {}", lockId, e);
exceptions.add(e);
}
}
if (!exceptions.isEmpty()) {
throw new JobExecutionException(exceptions);
}
return RepeatStatus.FINISHED;
}
}
@@ -22,4 +22,11 @@ public interface ShareAccountSchedularService {
void postDividend(Long dividendDetailId, Long savingsId);
/**
* Writes off an unclaimed share-dividend liability (see {@code m_share_dividend_lock}) to the
* bank's profit — called once a lock's 3-year release_due_date has passed with no member claim
* or manager action.
*/
void forfeitDividendLock(Long lockId);
}
@@ -18,8 +18,12 @@
*/
package org.apache.fineract.portfolio.shareaccounts.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@@ -27,14 +31,22 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
private final ShareAccountDividendRepository shareAccountDividendRepository;
private final SavingsAccountDomainService savingsAccountDomainService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
@@ -49,4 +61,69 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
}
/**
* 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)
public void forfeitDividendLock(final Long lockId) {
final Map<String, Object> lock = this.jdbcTemplate.queryForList("""
SELECT dividend_detail_id, currency_code, amount, status
FROM m_share_dividend_lock WHERE id = ?
""", lockId).stream().findFirst().orElseThrow();
if (!"UNCLAIMED".equals(lock.get("status"))) {
return;
}
final String currencyCode = (String) lock.get("currency_code");
final BigDecimal amount = (BigDecimal) lock.get("amount");
postForfeitureJournalEntry(lockId, currencyCode, amount);
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.
*/
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);
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)
.stream().findFirst().orElse(null);
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
return;
}
final AppUser currentUser = this.context.authenticatedUser();
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;
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(), ?, ?)
""";
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);
}
}
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
@@ -112,8 +113,10 @@ public class ShareAccountsConfiguration {
@Bean
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler);
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
context, jdbcTemplate);
}
@Bean
@@ -42,6 +42,15 @@ public class ShareProductDividentPayOutDetailsRepositoryWrapper {
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
}
/**
* Like {@link #save}, but flushes immediately so the generated id is guaranteed to be
* populated on the entity before the caller reads it (plain save() can leave a newly
* INSERTed entity's identity-generated id unset until the surrounding transaction flushes).
*/
public void saveAndFlush(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(shareProductDividendPayOutDetails);
}
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
}
@@ -115,6 +115,13 @@ public class ShareProductDividendAssembler {
numberOfShares += purchasedSharesData.getNumberOfShares();
} else {
numberOfShares -= purchasedSharesData.getNumberOfShares();
// A purchase that hadn't yet cleared the Minimum Active Period is skipped
// above (via `continue`) and never added here — but its later full/partial
// redemption is never skipped, so without this floor numberOfShares (and
// therefore this account's dividend) can go negative.
if (numberOfShares < 0) {
numberOfShares = 0;
}
}
}
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMappe
import com.google.gson.JsonObject;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -85,6 +86,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final CommandSourceRepository commandSourceRepository;
private final JdbcTemplate jdbcTemplate;
// 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;
@Override
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
@@ -152,12 +159,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
final BigDecimal dividendAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
if (dividendPayOutDetails == null) {
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
}
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
@@ -172,6 +184,68 @@ 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.
*/
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
final LocalDate dividendPeriodEndDate) {
final Integer overlappingCount = 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(dividendPeriodEndDate),
java.sql.Date.valueOf(dividendPeriodStartDate));
if (overlappingCount != null && overlappingCount > 0) {
throw new DividentProcessingException("period.overlaps.existing.dividend",
"This product already has a dividend declared for a period that overlaps " + dividendPeriodStartDate + " to "
+ dividendPeriodEndDate + ". Choose a period that doesn't overlap with an existing declaration.");
}
}
/**
* 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.
*/
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
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);
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 (reserveAccountId == null || payableDividendsAccountId == null) {
return;
}
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);
}
@Override
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
try {
@@ -182,6 +256,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
}
dividendPayOutDetails.approveDividendPayout();
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
makeDividendsClaimable(PayOutDetailId);
return new CommandProcessingResultBuilder() //
.withEntityId(PayOutDetailId) //
.build();
@@ -191,6 +266,44 @@ 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.
*/
private void makeDividendsClaimable(final Long payOutDetailId) {
final LocalDate today = DateUtils.getBusinessLocalDate();
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
final java.sql.Date releaseDueDate = java.sql.Date.valueOf(today.plusYears(3));
// A linked savings account is no longer required to create the unclaimed-liability
// record -- disbursement destination (loan / savings / cash) is chosen when the member
// actually claims it, not up front. So every member gets treated identically here, whether
// their share account has a linked savings account or not; currency is read straight off
// the share account itself.
jdbcTemplate.update("""
INSERT INTO m_share_dividend_lock
(dividend_detail_id, client_id, savings_account_id, currency_code, amount, locked_date, release_due_date,
status, created_date, last_modified_date)
SELECT sad.id, sa.client_id, sa.savings_account_id, sa.currency_code, sad.amount, ?, ?, 'UNCLAIMED', now(), now()
FROM m_share_account_dividend_details sad
JOIN m_share_account sa ON sa.id = sad.account_id
WHERE sad.dividend_pay_out_id = ? AND sad.status = 100 AND sad.amount > 0
""", lockedDate, releaseDueDate, payOutDetailId);
// A member whose entitlement worked out to zero has nothing to claim; everyone else is now
// covered by the unclaimed-liability row above -- both get marked posted.
jdbcTemplate.update("UPDATE m_share_account_dividend_details SET status = 300 WHERE dividend_pay_out_id = ? AND status = 100",
payOutDetailId);
}
@Override
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
try {
@@ -117,11 +117,11 @@ public class ShareProductsConfiguration {
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
ProductPendingEditChargeRepository pendingEditChargeRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository);
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
}
}
@@ -508,4 +508,11 @@
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
<include file="parts/0457_seed_forfeit_unclaimed_dividends_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0458_seed_dividend_financial_activity_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0459_speed_up_post_dividends_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0460_create_share_dividend_claim_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0461_rework_dividend_claim_for_gl_only_liability.xml" relativeToChangelogFile="true"/>
<include file="parts/0462_dividend_lock_no_savings_account_required.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?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">
<!--
Tracks the 3-year lock lifecycle of a paid-out Share Dividend (one row per
m_share_account_dividend_details row actually posted). While LOCKED, the member cannot
withdraw the amount (enforced via a native Fineract savings "hold" — hold_transaction_id
references the m_savings_account_transaction row created by the holdAmount command). If a
Bank Manager applies it to an overdue loan before the 3 years are up, the row moves to
APPLIED_TO_LOAN; if untouched, the "Forfeit Unclaimed Dividends" scheduled job moves it to
FORFEITED and sweeps the amount back into the bank's profit.
-->
<changeSet id="0456_create_share_dividend_lock" author="CBS">
<createTable tableName="m_share_dividend_lock">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="dividend_detail_id" type="BIGINT">
<constraints nullable="false" unique="true"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="hold_transaction_id" type="BIGINT"/>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="locked_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="release_due_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="LOCKED">
<constraints nullable="false"/>
</column>
<column name="applied_loan_id" type="BIGINT"/>
<column name="applied_transaction_id" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="last_modified_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_share_dividend_lock" indexName="idx_sdl_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_share_dividend_lock" indexName="idx_sdl_client_id">
<column name="client_id"/>
</createIndex>
<createIndex tableName="m_share_dividend_lock" indexName="idx_sdl_release_due_date">
<column name="release_due_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?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">
<!--
Registers the daily "Forfeit Unclaimed Dividends" job (see
ForfeitUnclaimedDividendsConfig/Tasklet) that sweeps 3-year-locked, unclaimed share
dividends back into the bank's profit. Mirrors the "Post Dividends For Shares" job seed
(id=20) in 0002_initial_data.xml; id=50 chosen as the next free id (current max was 49).
-->
<changeSet id="0457_seed_forfeit_unclaimed_dividends_job" author="CBS">
<insert tableName="job">
<column name="id" valueNumeric="50"/>
<column name="name" value="Forfeit Unclaimed Dividends"/>
<column name="display_name" value="Forfeit Unclaimed Dividends"/>
<column name="cron_expression" value="0 0 1 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Forfeit Unclaimed DividendsJobDetail1 _ DEFAULT"/>
<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="true"/>
<column name="short_name" value="SH_FDIV"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,82 @@
<?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">
<!--
Dedicated GL accounts + Financial Activity mappings for the Share Dividend feature
(declaration -> Reserve for Dividend / Dividend Payable, payout -> Dividend Payable via
native Fineract accounting, 3-year-unclaimed forfeiture -> Dividend Forfeited Income).
Without these three mappings: (1) native dividend payout accounting throws
FinancialActivityAccountNotFoundException for any savings product with accounting
enabled — this blocks real payouts, not just the new declaration/forfeiture entries added
in this fork; (2) the declaration-time Reserve entry and the 3-year forfeiture entry are
silently skipped (by design, so a missing mapping never blocks the dividend workflow
itself) until this is run.
-->
<changeSet id="0458_seed_dividend_financial_activity_accounts" author="CBS">
<insert tableName="acc_gl_account">
<column name="name" value="Reserve for Dividend"/>
<column name="gl_code" value="DIVRES01"/>
<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="Amount set aside from profit when a Share Dividend is declared, released to Dividend Payable"/>
</insert>
<insert tableName="acc_gl_account">
<column name="name" value="Dividend Payable"/>
<column name="gl_code" value="DIVPAY01"/>
<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="Declared Share Dividend awaiting payout to members' savings accounts"/>
</insert>
<insert tableName="acc_gl_account">
<column name="name" value="Dividend Forfeited Income"/>
<column name="gl_code" value="DIVFORF01"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="4"/>
<column name="description" value="Share Dividend locked for 3 years and never claimed, swept back into profit"/>
</insert>
<!-- Financial Activity ids: 201 = Payable Dividends, 301 = Reserve for Dividend,
400 = Dividend Forfeited Income (see AccountingConstants.FinancialActivity). -->
<sql>
INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type)
SELECT id, 201 FROM acc_gl_account WHERE gl_code = 'DIVPAY01';
</sql>
<sql>
INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type)
SELECT id, 301 FROM acc_gl_account WHERE gl_code = 'DIVRES01';
</sql>
<sql>
INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type)
SELECT id, 400 FROM acc_gl_account WHERE gl_code = 'DIVFORF01';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,41 @@
<?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">
<!--
Dividend approval stays a lightweight, synchronous status-flip only (a large declaration
with many members must never be paid out inline inside the Approve HTTP request — that
would spike DB load and risk request timeouts). Instead, the existing "Post Dividends For
Shares" job (seeded in 0002_initial_data.xml, id=20) now runs every 30 minutes instead of
once a day, so members still get paid out quickly without the approve action itself doing
any heavy lifting.
-->
<changeSet id="0459_speed_up_post_dividends_job" author="CBS">
<update tableName="job">
<column name="cron_expression" value="0 0/30 * 1/1 * ? *"/>
<where>name = 'Post Dividends For Shares'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,85 @@
<?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">
<!--
Maker-checker workflow letting a member claim a still-LOCKED dividend before its 3-year
hold would otherwise end in either loan-recovery application or forfeiture. A clerk (maker)
submits the claim on the member's behalf; a Bank Manager (checker) approves it — only
allowed while the client has no overdue loan EMI, since the whole point of the lock is to
protect that recovery option — which releases the hold (fully or partially) so the member
can freely withdraw, or rejects it, leaving the lock untouched.
-->
<changeSet id="0460_create_share_dividend_claim_request" author="CBS">
<createTable tableName="m_share_dividend_claim_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="lock_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="requested_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<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="manager_id" type="BIGINT"/>
<column name="manager_name" type="VARCHAR(100)"/>
<column name="rejection_reason" type="VARCHAR(500)"/>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="last_modified_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_share_dividend_claim_request" baseColumnNames="lock_id"
constraintName="FK_share_dividend_claim_request_lock_id"
referencedTableName="m_share_dividend_lock" referencedColumnNames="id"/>
<createIndex tableName="m_share_dividend_claim_request" indexName="idx_sdcr_lock_id">
<column name="lock_id"/>
</createIndex>
<createIndex tableName="m_share_dividend_claim_request" indexName="idx_sdcr_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?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">
<!--
Real-world rework: an approved dividend no longer gets credited into the member's savings
account and then held — it stays purely as the "Dividend Payable" GL liability declared
at approval time (m_share_dividend_lock now tracks that per-member UNCLAIMED liability,
not a savings hold; hold_transaction_id is unused going forward, kept only for historical
rows). Disbursement only happens when the member actually comes to claim it — at that
point the claim request records exactly how it's split: some straight to an overdue loan,
the rest to savings credit or cash, whichever the member wants.
-->
<changeSet id="0461_rework_dividend_claim_for_gl_only_liability" author="CBS">
<addDefaultValue tableName="m_share_dividend_lock" columnName="status" defaultValue="UNCLAIMED"/>
<addColumn tableName="m_share_dividend_claim_request">
<column name="loan_id" type="BIGINT"/>
<column name="loan_apply_amount" type="DECIMAL(19,6)"/>
<column name="savings_apply_amount" type="DECIMAL(19,6)"/>
<column name="cash_apply_amount" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A member whose share account has no linked savings account is now handled EXACTLY like any
other member — an UNCLAIMED "Dividend Payable" liability record is created for them at
approval time too (previously they were skipped and left in limbo, only resolvable via the
old credit-and-hold job/retry path once a savings account eventually got linked — an
inconsistent second mechanism). Since disbursement destination is chosen at claim time (loan
/ savings / cash), a pre-linked savings account is no longer required to even have a claim.
currency_code is stored directly on the lock (from the share account itself) so nothing here
depends on a savings account existing at all.
-->
<changeSet id="0462_dividend_lock_no_savings_account_required" author="CBS">
<dropNotNullConstraint tableName="m_share_dividend_lock" columnName="savings_account_id" columnDataType="BIGINT"/>
<addColumn tableName="m_share_dividend_lock">
<column name="currency_code" type="VARCHAR(3)"/>
</addColumn>
<sql>
UPDATE m_share_dividend_lock l
SET currency_code = (
SELECT sa.currency_code FROM m_share_account_dividend_details sadd
JOIN m_share_account sa ON sa.id = sadd.account_id
WHERE sadd.id = l.dividend_detail_id
)
WHERE l.currency_code IS NULL
</sql>
<dropNotNullConstraint tableName="m_share_dividend_claim_request" columnName="savings_account_id" columnDataType="BIGINT"/>
</changeSet>
</databaseChangeLog>