From 9dd4f8bedb1cb25345064c410bcce2e95189667a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 11 Aug 2026 12:42:45 +0530 Subject: [PATCH] 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 --- .../common/AccountingConstants.java | 4 +- .../infrastructure/jobs/service/JobName.java | 1 + .../api/DividendPaidReportApiResource.java | 131 +++++ .../api/ProfitAndLossApiResource.java | 98 ++++ .../api/DividendClaimRequestApiResource.java | 513 ++++++++++++++++++ .../api/DividendDashboardApiResource.java | 430 +++++++++++++++ .../api/PendingDividendsApiResource.java | 86 +++ .../ForfeitUnclaimedDividendsConfig.java | 63 +++ .../ForfeitUnclaimedDividendsTasklet.java | 67 +++ .../service/ShareAccountSchedularService.java | 7 + .../ShareAccountSchedularServiceImpl.java | 77 +++ .../start/ShareAccountsConfiguration.java | 7 +- ...ividentPayOutDetailsRepositoryWrapper.java | 9 + .../ShareProductDividendAssembler.java | 7 + ...WritePlatformServiceJpaRepositoryImpl.java | 115 +++- .../starter/ShareProductsConfiguration.java | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 7 + .../parts/0456_create_share_dividend_lock.xml | 83 +++ ...7_seed_forfeit_unclaimed_dividends_job.xml | 55 ++ ...d_dividend_financial_activity_accounts.xml | 82 +++ .../0459_speed_up_post_dividends_job.xml | 41 ++ ...60_create_share_dividend_claim_request.xml | 85 +++ ...k_dividend_claim_for_gl_only_liability.xml | 46 ++ ...idend_lock_no_savings_account_required.xml | 54 ++ 24 files changed, 2066 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendPaidReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/ProfitAndLossApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/PendingDividendsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_create_share_dividend_lock.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_seed_dividend_financial_activity_accounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_speed_up_post_dividends_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_share_dividend_claim_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_rework_dividend_claim_for_gl_only_liability.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_dividend_lock_no_savings_account_required.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index 788ae2a56..0713de32c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -436,7 +436,9 @@ public final class AccountingConstants { CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), // OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), // ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), // - PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); // + PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), // + RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), // + DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); // private final Integer value; private final String code; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 4e1ab4200..1812b2bb1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -38,6 +38,7 @@ public enum JobName { GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), // GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), // POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), // + FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendPaidReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendPaidReportApiResource.java new file mode 100644 index 000000000..7001c8b99 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendPaidReportApiResource.java @@ -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 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> 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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/ProfitAndLossApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/ProfitAndLossApiResource.java new file mode 100644 index 000000000..af3c92d1b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/ProfitAndLossApiResource.java @@ -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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java new file mode 100644 index 000000000..a5ae75d14 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -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. + * + *

+ * 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> 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 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 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 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> 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 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 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 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 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 loadRecord(final Long id) { + final List> 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java new file mode 100644 index 000000000..980a47ada --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java @@ -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 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 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 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> 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 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> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase()) + : jdbcTemplate.queryForList(sql); + + final JsonArray result = new JsonArray(); + for (final Map 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> 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 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> 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 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 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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/PendingDividendsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/PendingDividendsApiResource.java new file mode 100644 index 000000000..2115cea6c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/PendingDividendsApiResource.java @@ -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> 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsConfig.java new file mode 100644 index 000000000..4312f8d8e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsConfig.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java new file mode 100644 index 000000000..37e77ca5f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java @@ -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 exceptions = new ArrayList<>(); + final List 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularService.java index c73d855c7..73cad0165 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularService.java @@ -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); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index 5cfbf2895..ffc9f7f8a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -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 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); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index c92890bda..eed5904cc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividentPayOutDetailsRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividentPayOutDetailsRepositoryWrapper.java index 753e610a6..f5a62f180 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividentPayOutDetailsRepositoryWrapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividentPayOutDetailsRepositoryWrapper.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java index ef2d8d7fc..3755e227f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java @@ -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; + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index e3714d10d..9f831565c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java index 0134e629d..3dce44518 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java @@ -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); } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 6ea4f3c0d..bd2bd1017 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -508,4 +508,11 @@ + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_create_share_dividend_lock.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_create_share_dividend_lock.xml new file mode 100644 index 000000000..e86d34f5d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_create_share_dividend_lock.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml new file mode 100644 index 000000000..44a3207b6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_seed_dividend_financial_activity_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_seed_dividend_financial_activity_accounts.xml new file mode 100644 index 000000000..a6a6b8cfc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_seed_dividend_financial_activity_accounts.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type) + SELECT id, 201 FROM acc_gl_account WHERE gl_code = 'DIVPAY01'; + + + INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type) + SELECT id, 301 FROM acc_gl_account WHERE gl_code = 'DIVRES01'; + + + INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type) + SELECT id, 400 FROM acc_gl_account WHERE gl_code = 'DIVFORF01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_speed_up_post_dividends_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_speed_up_post_dividends_job.xml new file mode 100644 index 000000000..0d68f4b47 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_speed_up_post_dividends_job.xml @@ -0,0 +1,41 @@ + + + + + + + + + name = 'Post Dividends For Shares' + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_share_dividend_claim_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_share_dividend_claim_request.xml new file mode 100644 index 000000000..d367b57ff --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_share_dividend_claim_request.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_rework_dividend_claim_for_gl_only_liability.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_rework_dividend_claim_for_gl_only_liability.xml new file mode 100644 index 000000000..3475d95b9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_rework_dividend_claim_for_gl_only_liability.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_dividend_lock_no_savings_account_required.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_dividend_lock_no_savings_account_required.xml new file mode 100644 index 000000000..e056f7499 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_dividend_lock_no_savings_account_required.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + 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 + + + + + +