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:
+131
@@ -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());
|
||||
}
|
||||
}
|
||||
+98
@@ -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 & 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());
|
||||
}
|
||||
}
|
||||
+513
@@ -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());
|
||||
}
|
||||
}
|
||||
+430
@@ -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());
|
||||
}
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+77
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+114
-1
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+83
@@ -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>
|
||||
+55
@@ -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>
|
||||
+82
@@ -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>
|
||||
+41
@@ -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>
|
||||
+85
@@ -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>
|
||||
+46
@@ -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>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
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>
|
||||
Reference in New Issue
Block a user