From 73ec85cfd6de77296ba0394eff84374684ed6b97 Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 25 Aug 2026 11:31:49 +0530 Subject: [PATCH] feat: implement account register, branch day, voucher verification, and share dividend backend APIs --- .../common/AccountingConstants.java | 1 + .../infrastructure/jobs/service/JobName.java | 1 + .../DividendYearwiseReportApiResource.java | 185 +++ .../api/DistributableProfitApiResource.java | 278 +++++ .../api/IncomeProfitTransferApiResource.java | 180 +++ .../PostShareEntryFeeToProfitConfig.java | 60 + .../PostShareEntryFeeToProfitTasklet.java | 61 + .../service/IncomeProfitTransferService.java | 377 ++++++ .../api/AccountRegisterApiResource.java | 299 +++++ .../AmlFundTransferEvaluationService.java | 5 +- .../service/AmlRuleEvaluationServiceImpl.java | 11 +- .../api/BranchDayRegisterApiResource.java | 477 ++++++++ .../cashreport/api/CashReportApiResource.java | 173 +++ .../ShareAdvanceCollectionApiResource.java | 754 ++++++++++++ .../api/ShareCollectionApiResource.java | 16 +- .../ShareSavingsDebitRequestApiResource.java | 601 ++++++++++ .../ClerkDisbursementRequestApiResource.java | 22 +- .../api/DividendClaimRequestApiResource.java | 763 +++++------- .../api/DividendDashboardApiResource.java | 124 +- .../shareaccounts/data/ShareAccountData.java | 14 + .../domain/ShareAccountCharge.java | 9 +- .../ForfeitUnclaimedDividendsTasklet.java | 16 +- .../ShareAccountDataSerializer.java | 23 +- .../ShareAccountReadPlatformServiceImpl.java | 4 + .../ShareAccountSchedularServiceImpl.java | 78 +- ...WritePlatformServiceJpaRepositoryImpl.java | 1050 +++++++++-------- .../start/ShareAccountsConfiguration.java | 3 +- .../api/AllProductsDividendApiResource.java | 579 +++++++++ .../api/ShareDividendApiResource.java | 33 + ...hareDividendCapitalPreviewApiResource.java | 262 ++++ .../ShareProductDividendPayOutDetails.java | 18 + ...areDividendRoundingResidueReadService.java | 113 ++ .../ShareProductDividendAssembler.java | 55 +- ...WritePlatformServiceJpaRepositoryImpl.java | 586 ++++++++- .../starter/ShareProductsConfiguration.java | 4 +- .../api/VoucherVerificationApiResource.java | 429 +++++++ .../voucher/service/VoucherService.java | 105 +- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 21 + ...0_add_resolution_date_to_share_account.xml | 33 + ...71_make_share_account_savings_optional.xml | 31 + .../0472_create_share_advance_collection.xml | 88 ++ ...dd_collection_applied_to_share_account.xml | 43 + ...voucher_no_to_share_advance_collection.xml | 42 + ...5_add_share_applied_to_deposit_request.xml | 36 + .../0476_create_income_profit_transfer.xml | 89 ++ .../0477_add_share_entry_fee_profit_job.xml | 50 + ...dend_appropriation_accounts_and_config.xml | 116 ++ .../0479_dividend_claim_by_demand_draft.xml | 65 + .../0480_create_dividend_rounding_residue.xml | 146 +++ .../parts/0481_seed_dividend_paid_gl.xml | 68 ++ ...482_create_share_savings_debit_request.xml | 120 ++ ..._seed_share_savings_debit_payment_type.xml | 54 + .../parts/0484_voucher_no_dated_format.xml | 78 ++ ...485_backfill_flat_share_charge_amounts.xml | 36 + ...pay_unsettled_share_activation_charges.xml | 44 + .../0487_create_voucher_verification.xml | 81 ++ .../0488_add_voucher_exception_resolution.xml | 33 + .../parts/0489_create_branch_day_register.xml | 100 ++ ..._seed_bank_profit_non_distributable_gl.xml | 76 ++ 60 files changed, 8075 insertions(+), 1148 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.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 0713de32c..e79c7d784 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 @@ -437,6 +437,7 @@ public final class AccountingConstants { OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), // ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), // PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), // + DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), // RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), // DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); // 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 1812b2bb1..aca7da9a6 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 @@ -39,6 +39,7 @@ public enum JobName { GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), // POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), // FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // + POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java new file mode 100644 index 000000000..88e5e87ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java @@ -0,0 +1,185 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.dividendpaidreport.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Every declared dividend, financial year by financial year: how much was declared, how much members have collected, + * how much is still unclaimed, and how much has come back to profit. + * + * The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is + * still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the + * per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the + * money moved; the locks are what it moved for. + * + * The columns mirror the money's actual path, which begins and ends in the same place: + * + *
+ *   declare          Dr Bank Profit        Cr Dividend Payable
+ *   member claims    Dr Dividend Payable   Cr Dividend Paid  →  Cr Sponsor DD Payable
+ *   3 yrs unclaimed  Dr Dividend Payable   Cr Bank Profit
+ * 
+ * + * So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left + * is what is still sitting unclaimed and still within its three-year window. + */ +@Path("/v1/accounting/dividend-yearwise-report") +@Component +@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.") +@RequiredArgsConstructor +public class DividendYearwiseReportApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String report() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + // A declaration is labelled by the financial year its period CLOSES in, which is how a member + // and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined + // through the per-member detail rather than counted off the declaration, because a lock is only + // created for a member who actually had something to collect. + // Declared amounts and lock outcomes are aggregated separately before being joined by year. + // Summing the declaration amount across a join to its locks would multiply it by the number + // of members it was split between. + final List> rows = jdbcTemplate.queryForList(""" + WITH fy AS ( + SELECT d.id, + CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4 + THEN EXTRACT(YEAR FROM d.dividend_period_end_date) + ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start, + d.amount + FROM m_share_product_dividend_pay_out d + ), + declared AS ( + SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount + FROM fy GROUP BY fy_start + ), + outcomes AS ( + SELECT fy.fy_start, + COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount, + COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count, + COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount, + COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count, + COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount, + COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count, + MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due + FROM fy + LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id + LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id + GROUP BY fy.fy_start + ) + SELECT d.fy_start, d.declaration_count, d.declared_amount, + COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count, + COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count, + COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count, + o.earliest_release_due + FROM declared d + LEFT JOIN outcomes o ON o.fy_start = d.fy_start + ORDER BY d.fy_start DESC + """); + + // Rounding residue is tracked per declaration rather than per lock — it never belonged to a + // member, so no lock was ever created for it. Pulled separately and matched by year. + final Map residueByYear = new LinkedHashMap<>(); + for (final Map row : jdbcTemplate.queryForList(""" + SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4 + THEN EXTRACT(YEAR FROM period_end_date) + ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start, + COALESCE(SUM(residue_amount), 0) AS residue + FROM m_share_dividend_rounding_residue + GROUP BY fy_start + """)) { + residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue")); + } + + final List> years = new ArrayList<>(); + BigDecimal totalDeclared = BigDecimal.ZERO; + BigDecimal totalClaimed = BigDecimal.ZERO; + BigDecimal totalUnclaimed = BigDecimal.ZERO; + BigDecimal totalForfeited = BigDecimal.ZERO; + + for (final Map row : rows) { + final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue()); + final BigDecimal declared = (BigDecimal) row.get("declared_amount"); + final BigDecimal claimed = (BigDecimal) row.get("claimed_amount"); + final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount"); + final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount"); + final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO); + + final Map entry = new LinkedHashMap<>(); + entry.put("financialYear", financialYear); + entry.put("declarationCount", row.get("declaration_count")); + entry.put("declaredAmount", declared); + entry.put("claimedAmount", claimed); + entry.put("claimedCount", row.get("claimed_count")); + entry.put("unclaimedAmount", unclaimed); + entry.put("unclaimedCount", row.get("unclaimed_count")); + entry.put("forfeitedAmount", forfeited); + entry.put("forfeitedCount", row.get("forfeited_count")); + entry.put("roundingResidueAmount", residue); + // What has already gone back to profit for this year: forfeited entitlements plus the + // rounding residue swept at declaration time. + entry.put("returnedToProfitAmount", forfeited.add(residue)); + // When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed + // amount starts returning to profit. + entry.put("earliestReleaseDueDate", + row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due"))); + years.add(entry); + + totalDeclared = totalDeclared.add(declared); + totalClaimed = totalClaimed.add(claimed); + totalUnclaimed = totalUnclaimed.add(unclaimed); + totalForfeited = totalForfeited.add(forfeited); + } + + final Map response = new LinkedHashMap<>(); + response.put("years", years); + response.put("totalDeclared", totalDeclared); + response.put("totalClaimed", totalClaimed); + response.put("totalUnclaimed", totalUnclaimed); + response.put("totalForfeited", totalForfeited); + return gson.toJson(response); + } + + private String financialYearLabel(final int startYear) { + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java new file mode 100644 index 000000000..1e03e915e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java @@ -0,0 +1,278 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.profitandloss.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Date; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * How much profit a share dividend may actually be declared out of, for a given period. + * + * A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve + * Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared + * for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one + * place so the declaration screen and the declaration-time validation cannot disagree about the ceiling. + * + * Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration + * row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error. + */ +@Path("/v1/accounting/distributable-profit") +@Component +@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.") +@RequiredArgsConstructor +public class DistributableProfitApiResource { + + /** + * The year's closed-out profit, which is what a dividend may be declared against; kept in step with + * IncomeProfitTransferService.PROFIT_GL_CODE. + * + * Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would + * offer profit already distributed once for distribution again. + */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; + private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage"; + + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("excludeProductId") final Long excludeProductId) { + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final Date from; + final Date to; + try { + from = Date.valueOf(fromDate); + to = Date.valueOf(toDate); + } catch (final IllegalArgumentException e) { + throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format"); + } + if (to.before(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + + // A dividend is normally declared for a whole, closed financial year, and then the audited + // profit in Bank Profit is the figure to use. A period that is not a full financial year can + // only be an interim declaration, which necessarily runs against the still-open P&L. + final String financialYear = financialYearOf(from.toLocalDate()); + final LocalDate[] fyRange = financialYearRange(financialYear); + final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]); + + final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null; + final boolean yearClosed = closedProfit != null; + final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to); + + final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); + final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); + final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE); + + // Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate + // and nothing to distribute, so the whole waterfall collapses to zero rather than producing + // a negative "reserve" that would read as money owed back to the society. + final boolean profitable = netProfit.signum() > 0; + final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO; + final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO; + + final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount) + : BigDecimal.ZERO; + + final List> existingDeclarations = declarationsOverlapping(from, to, excludeProductId); + BigDecimal alreadyDeclared = BigDecimal.ZERO; + for (final Map declaration : existingDeclarations) { + alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount")); + } + + // An over-declaration that already happened (or appropriation rates raised after the fact) + // must not present as negative headroom, which a caller could read as a credit. + final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO); + + final Map response = new LinkedHashMap<>(); + response.put("fromDate", fromDate); + response.put("toDate", toDate); + response.put("financialYear", financialYear); + response.put("coversWholeFinancialYear", coversWholeFinancialYear); + response.put("yearClosed", yearClosed); + // Names the figure so the screen can say where it came from — the closed books, or a P&L + // that is still moving — rather than showing a bare number the officer has to interpret. + response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS"); + response.put("netProfit", netProfit); + response.put("statutoryReservePercentage", statutoryReservePercentage); + response.put("statutoryReserveAmount", statutoryReserveAmount); + response.put("educationFundPercentage", educationFundPercentage); + response.put("educationFundAmount", educationFundAmount); + response.put("distributableProfit", distributableProfit); + response.put("alreadyDeclared", alreadyDeclared); + response.put("availableForDeclaration", availableForDeclaration); + response.put("maximumRatePercentage", maximumRatePercentage); + response.put("existingDeclarations", existingDeclarations); + return gson.toJson(response); + } + + /** + * The year's profit as the books actually record it, once the year has been closed. + * + * A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the + * Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather + * than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a + * closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it. + * + * Returns null when the year has not been closed, which the caller reports as a blocking condition rather than + * silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit + * figure that is still moving. + */ + private BigDecimal closedProfitForFinancialYear(final String financialYear) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount == null || closedCount == 0) { + return null; + } + final LocalDate[] range = financialYearRange(financialYear); + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** "2026-27" -> 1 April 2026 to 31 March 2027. */ + private LocalDate[] financialYearRange(final String financialYear) { + try { + final int startYear = Integer.parseInt(financialYear.substring(0, 4)); + return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) }; + } catch (final RuntimeException e) { + throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear); + } + } + + /** The Indian financial year a date falls in, as a "YYYY-YY" label. */ + private String financialYearOf(final LocalDate date) { + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** + * Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal + * entries only. Used only for the interim case — a period inside a year that has not been closed yet. + */ + private BigDecimal netProfitForPeriod(final Date from, final Date to) { + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false + AND je.entry_date BETWEEN ? AND ? + """, from, to); + final BigDecimal totalIncome = (BigDecimal) row.get("total_income"); + final BigDecimal totalExpense = (BigDecimal) row.get("total_expense"); + return totalIncome.subtract(totalExpense); + } + + /** + * Every dividend already declared against a period overlapping this one, across ALL share products — the + * appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable + * profit as the first. + * + * excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping + * declaration is separately blocked outright. + */ + private List> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) { + final StringBuilder sql = new StringBuilder(""" + SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount, + d.dividend_period_start_date, d.dividend_period_end_date, d.status + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ? + """); + final List params = new ArrayList<>(List.of(to, from)); + if (excludeProductId != null) { + sql.append(" AND d.product_id <> ? "); + params.add(excludeProductId); + } + sql.append(" ORDER BY d.dividend_period_start_date, d.id"); + + final List> declarations = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map declaration = new LinkedHashMap<>(); + declaration.put("declarationId", row.get("declaration_id")); + declaration.put("productId", row.get("product_id")); + declaration.put("productName", row.get("product_name")); + declaration.put("amount", row.get("amount")); + declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date"))); + declaration.put("status", row.get("status")); + declarations.add(declaration); + } + return declarations; + } + + /** + * A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling), + * which is 0 — not a failure. Only an enabled row with a value applies. + */ + private BigDecimal configuredPercentage(final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + + private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) { + return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java new file mode 100644 index 000000000..abae1f99f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java @@ -0,0 +1,180 @@ +package org.apache.fineract.accounting.yearend.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Financial year-end closing of the share entry fee into the Bank Profit equity GL. + * + *

+ * {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST} + * performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so + * this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early. + */ +@Path("/v1/accounting/incomeprofittransfer") +@Component +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class IncomeProfitTransferApiResource { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final IncomeProfitTransferService service; + private final Gson gson = new Gson(); + + // ══════════════════════════════════════════════════════════════════════ + // GET — how much is waiting to be posted, and what has been posted before + // ══════════════════════════════════════════════════════════════════════ + @GET + public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam, + @QueryParam("financialYear") final String financialYearParam) { + + this.context.authenticatedUser(); + + final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank() + ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE + : incomeGlCodeParam.trim(); + final Map incomeGl = service.loadGlByCode(incomeGlCode); + final Map profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE); + + final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear() + : financialYearParam.trim(); + final LocalDate[] range = service.financialYearRange(financialYear); + + final Long incomeGlId = toLong(incomeGl.get("id")); + final BigDecimal pending = service.balanceOf(incomeGlId, range); + final int entryCount = service.entryCountOf(incomeGlId, range); + + final List> history = jdbcTemplate + .queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, " + + " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name " + + " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id " + + " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId); + + final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year"))); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("fromDate", range[0].toString()); + out.put("toDate", range[1].toString()); + out.put("incomeGlCode", incomeGl.get("gl_code")); + out.put("incomeGlName", incomeGl.get("name")); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + out.put("pendingAmount", pending); + out.put("entryCount", entryCount); + out.put("alreadyPosted", alreadyPosted); + out.put("postedHistory", history); + out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST — close the income GL into Bank Profit + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + public Response post(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE + : getString(json, "incomeGlCode").trim(); + final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear() + : getString(json, "financialYear").trim(); + + // strict = true: a manual posting reports "already closed" or "nothing to post" as an + // error, so the user is told why nothing happened. + final Map result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(), + sanitiseNote(getString(json, "note")), true); + + return Response.ok(gson.toJson(result)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /close-year — close the WHOLE P&L into Bank Profit + // ══════════════════════════════════════════════════════════════════════ + + /** + * Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit. + * + * This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower + * share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that + * has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts. + */ + @POST + @Path("/close-year") + @Transactional + public Response closeYear(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear() + : getString(json, "financialYear").trim(); + + // strict = true: a manual close reports "nothing left to close" as an error, so the user is + // told why nothing happened rather than getting a silent success. + final Map result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(), + sanitiseNote(getString(json, "note")), true); + + return Response.ok(gson.toJson(result)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + return new JsonObject(); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON."); + } + } + + private String sanitiseNote(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + final String trimmed = raw.trim(); + return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed; + } + + private String getString(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java new file mode 100644 index 000000000..d237b8c5b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit; + +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class PostShareEntryFeeToProfitConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private IncomeProfitTransferService incomeProfitTransferService; + + @Bean + protected Step postShareEntryFeeToProfitStep() { + return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository) + .tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build(); + } + + @Bean + public Job postShareEntryFeeToProfitJob() { + return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep()) + .incrementer(new RunIdIncrementer()).build(); + } + + @Bean + public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() { + return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java new file mode 100644 index 000000000..4f12d5628 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java @@ -0,0 +1,61 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; + +/** + * Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank + * Profit equity GL, leaving the income account at nil for the new year. + * + *

+ * Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged + * rather than failing the job. Only a genuine posting failure is raised. + */ +@Slf4j +@RequiredArgsConstructor +public class PostShareEntryFeeToProfitTasklet implements Tasklet { + + /** Fineract's system user id, used because a scheduled run has no logged-in user. */ + private static final long SYSTEM_USER_ID = 1L; + + private final IncomeProfitTransferService incomeProfitTransferService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + final String financialYear = incomeProfitTransferService.currentFinancialYear(); + + // Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit + // is now what the share dividend declaration reads as "the year's profit", and a partial close + // would leave most of the profit still sitting in the income GLs — understating what may be + // distributed. Per-GL guards inside closeAllToProfit make a re-run safe. + final Map result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system", + "Automatic year-end closing", false); + + log.info("Year-end closing for {}: {}", financialYear, result.get("message")); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java new file mode 100644 index 000000000..464ddee50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -0,0 +1,377 @@ +package org.apache.fineract.accounting.yearend.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes an income GL into the Bank Profit equity GL at financial year end. + * + *

+ *   Dr  Share Entry Fee Income (10.4)   = balance for the year
+ *   Cr  Bank Profit (BANKPROFIT01)
+ * 
+ * + * Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as + * retained profit. + * + *

+ * Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are + * protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a + * logged-in user. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class IncomeProfitTransferService { + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int DEBIT = 2; + private static final int CREDIT = 1; + + /** Equity GL that accumulates profit. */ + /** + * The year's profit is closed into the Non-Distributable account, not plain Bank Profit. + * + * A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once + * the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years' + * leftovers, and the next declaration would be sized on profit that had already been through a distribution + * decision — offering the same money for distribution twice. + */ + public static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + /** Income GL closed to profit by the year-end job — the share entry fee. */ + public static final String DEFAULT_INCOME_GL_CODE = "10.4"; + + private final JdbcTemplate jdbcTemplate; + + /** + * Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when + * there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not + * errors. + * + * @param strict + * when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it + * returns quietly. + */ + @Transactional + public Map postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName, + final String note, final boolean strict) { + + final Map incomeGl = loadGlByCode(incomeGlCode); + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Long incomeGlId = toLong(incomeGl.get("id")); + final Long profitGlId = toLong(profitGl.get("id")); + + final LocalDate[] range = financialYearRange(financialYear); + final Long officeId = headOfficeId(); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("incomeGlCode", incomeGl.get("gl_code")); + out.put("incomeGlName", incomeGl.get("name")); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?", + Integer.class, financialYear, incomeGlId, officeId); + if (existing != null && existing > 0) { + if (strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted", + incomeGl.get("name") + " has already been closed to profit for " + financialYear + "."); + } + out.put("posted", false); + out.put("reason", "Already closed for " + financialYear); + return out; + } + + final BigDecimal balance = balanceOf(incomeGlId, range); + if (balance.compareTo(BigDecimal.ZERO) <= 0) { + if (strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post", + "There is no income in " + incomeGl.get("name") + " to close for " + financialYear + "."); + } + out.put("posted", false); + out.put("reason", "No income to close for " + financialYear); + return out; + } + + final int entryCount = entryCountOf(incomeGlId, range); + final String currencyCode = resolveCurrency(incomeGlId); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate postingDate = range[1]; + final String txnId = "YEPROFIT-" + System.nanoTime(); + final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit" + + (note != null ? " | " + note : ""); + + insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc); + insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, + "Profit for " + financialYear + " from " + incomeGl.get("name")); + + jdbcTemplate.update( + "INSERT INTO m_income_profit_transfer " + + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, " + + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, + entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note); + + log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"), + financialYear); + + out.put("posted", true); + out.put("amountPostedToProfit", balance); + out.put("entryCount", entryCount); + out.put("postedOnDate", postingDate.toString()); + out.put("glTransactionId", txnId); + out.put("currency", currencyCode); + out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + "."); + return out; + } + + /** + * Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real + * net profit. + * + *

+     *   each income GL:    Dr  Income GL      Cr  Bank Profit     (income raises profit)
+     *   each expense GL:   Dr  Bank Profit    Cr  Expense GL      (expense reduces profit)
+     * 
+ * + * {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes + * the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" — + * the share dividend declaration in particular — depends on this having run, because a partial close leaves the + * profit split across two places and understates it. + * + * Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a + * re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was. + * Accounts with a nil balance are skipped rather than posted as zero entries. + */ + @Transactional + public Map closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note, + final boolean strict) { + + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Long profitGlId = toLong(profitGl.get("id")); + final LocalDate[] range = financialYearRange(financialYear); + final Long officeId = headOfficeId(); + + // 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a + // historical balance but must not attract new postings. + final List> plAccounts = jdbcTemplate.queryForList( + "SELECT id, gl_code, name, classification_enum FROM acc_gl_account " + + " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code"); + + final List> closed = new ArrayList<>(); + final List> skipped = new ArrayList<>(); + BigDecimal totalIncome = BigDecimal.ZERO; + BigDecimal totalExpense = BigDecimal.ZERO; + + for (final Map gl : plAccounts) { + final Long glId = toLong(gl.get("id")); + final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4; + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?", + Integer.class, financialYear, glId, officeId); + if (existing != null && existing > 0) { + skipped.add(skipRow(gl, "Already closed for " + financialYear)); + continue; + } + + // balanceOf returns credit-less-debit. For an income GL that is the income earned; for an + // expense GL the natural balance is a debit, so the same figure comes back negative and + // is flipped here to read as a positive expense. + final BigDecimal rawBalance = balanceOf(glId, range); + final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate(); + if (balance.compareTo(BigDecimal.ZERO) <= 0) { + skipped.add(skipRow(gl, "Nothing to close for " + financialYear)); + continue; + } + + final int entryCount = entryCountOf(glId, range); + final String currencyCode = resolveCurrency(glId); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate postingDate = range[1]; + final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime(); + final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit" + + (note != null ? " | " + note : ""); + + if (isIncome) { + insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr); + insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, + "Profit for " + financialYear + " from " + gl.get("name")); + totalIncome = totalIncome.add(balance); + } else { + insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr); + insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, + "Expense for " + financialYear + " from " + gl.get("name")); + totalExpense = totalExpense.add(balance); + } + + jdbcTemplate.update("INSERT INTO m_income_profit_transfer " + + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, " + + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId, + java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId, + java.sql.Date.valueOf(postingDate), userId, userName, note); + + final Map row = new LinkedHashMap<>(); + row.put("glCode", gl.get("gl_code")); + row.put("glName", gl.get("name")); + row.put("type", isIncome ? "INCOME" : "EXPENSE"); + row.put("amount", balance); + row.put("entryCount", entryCount); + row.put("glTransactionId", txnId); + closed.add(row); + } + + if (closed.isEmpty() && strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close", + "There is nothing left to close to profit for " + financialYear + "."); + } + + final BigDecimal netProfit = totalIncome.subtract(totalExpense); + log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense, + netProfit, profitGl.get("name")); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("fromDate", range[0].toString()); + out.put("toDate", range[1].toString()); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + out.put("totalIncomeClosed", totalIncome); + out.put("totalExpenseClosed", totalExpense); + out.put("netProfitPosted", netProfit); + out.put("accountsClosed", closed); + out.put("accountsSkipped", skipped); + out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit " + + netProfit + "."); + return out; + } + + private Map skipRow(final Map gl, final String reason) { + final Map row = new LinkedHashMap<>(); + row.put("glCode", gl.get("gl_code")); + row.put("glName", gl.get("name")); + row.put("reason", reason); + return row; + } + + /** + * The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the + * year has been closed. + * + * Only entries dated inside the financial year count, so each year's profit is separable rather than being one + * ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide + * whether that is a blocking condition. + */ + public BigDecimal profitForFinancialYear(final String financialYear) { + final LocalDate[] range = financialYearRange(financialYear); + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final BigDecimal balance = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " + + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?", + BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** Whether the year's P&L has been closed into Bank Profit at all. */ + public boolean isYearClosed(final String financialYear) { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", + Integer.class, financialYear); + return count != null && count > 0; + } + + /** Net credit balance sitting in the income GL for the year. */ + public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) { + final BigDecimal balance = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " + + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?", + BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** How many fee postings made up that balance. */ + public int entryCountOf(final Long incomeGlId, final LocalDate[] range) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + + " AND type_enum = ? AND entry_date BETWEEN ? AND ?", + Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return count == null ? 0 : count; + } + + /** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */ + public String currentFinancialYear() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** 1 April to 31 March for a "YYYY-YY" label. */ + public LocalDate[] financialYearRange(final String financialYear) { + try { + final int startYear = Integer.parseInt(financialYear.substring(0, 4)); + return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) }; + } catch (RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid", + "financialYear must look like 2026-27. Received: " + financialYear); + } + } + + public Map loadGlByCode(final String glCode) { + try { + return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist."); + } + } + + public Long headOfficeId() { + return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); + } + + private String resolveCurrency(final Long glAccountId) { + try { + final String code = jdbcTemplate.queryForObject( + "SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class, + glAccountId); + return code == null ? "INR" : code; + } catch (EmptyResultDataAccessException e) { + return "INR"; + } + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) { + jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId, + now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC), + now.atOffset(ZoneOffset.UTC)); + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java new file mode 100644 index 000000000..3d78e5787 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java @@ -0,0 +1,299 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.accountregister.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.Date; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Account register for one date: which accounts were opened and which were closed that day, with + * each account's balance as it stood on that date alongside what it holds now. + * + * The dated balance is read from the running balance stamped on the last transaction up to that + * date, rather than recomputed from the account's current figure — a current balance tells you + * nothing about what the account held three weeks ago. + */ +@Path("/v1/accountregister") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class AccountRegisterApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + @GET + public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) { + this.context.authenticatedUser(); + + final LocalDate asOn; + if (dateParam != null && !dateParam.isBlank()) { + try { + asOn = LocalDate.parse(dateParam); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid", + "Date must be in yyyy-MM-dd format."); + } + } else { + asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + } + + final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase(); + final List> rows = new ArrayList<>(); + + if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) { + rows.addAll(depositAccounts(asOn, want)); + } + if (matches(want, "SHARE")) { + rows.addAll(shareAccounts(asOn)); + } + + // Opened and closed counts come from the rows themselves rather than separate COUNT queries, + // so the totals can never disagree with the list they summarise. + int opened = 0; + int closed = 0; + BigDecimal balanceOnDate = BigDecimal.ZERO; + BigDecimal currentBalance = BigDecimal.ZERO; + for (final Map r : rows) { + if (Boolean.TRUE.equals(r.get("openedOnDate"))) { + opened++; + } + if (Boolean.TRUE.equals(r.get("closedOnDate"))) { + closed++; + } + balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate"))); + currentBalance = currentBalance.add(dec(r.get("currentBalance"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("date", asOn.toString()); + out.put("section", want); + out.put("accounts", rows); + out.put("count", rows.size()); + out.put("openedCount", opened); + out.put("closedCount", closed); + out.put("totalBalanceOnDate", balanceOnDate); + out.put("totalCurrentBalance", currentBalance); + return gson.toJson(out); + } + + private static boolean matches(final String want, final String section) { + return "ALL".equals(want) || want.equals(section); + } + + private static BigDecimal dec(final Object o) { + return o instanceof BigDecimal b ? b : BigDecimal.ZERO; + } + + /** + * Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told + * apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring. + * + * Accounts submitted after the date are excluded — on that date they did not exist, and listing + * them would make a register of a past day describe a present one. + */ + private List> depositAccounts(final LocalDate asOn, final String want) { + final Date date = Date.valueOf(asOn); + + final StringBuilder typeFilter = new StringBuilder(); + if (!"ALL".equals(want)) { + final int type = switch (want) { + case "SAVINGS" -> 100; + case "FD" -> 200; + case "RD" -> 300; + default -> 0; + }; + if (type > 0) { + typeFilter.append(" AND sa.deposit_type_enum = ").append(type); + } + } + + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum, + sa.activatedon_date, sa.closedon_date, sa.submittedon_date, + sa.account_balance_derived, + c.display_name AS client_name, + p.name AS product_name, + (SELECT t.running_balance_derived + FROM m_savings_account_transaction t + WHERE t.savings_account_id = sa.id + AND COALESCE(t.is_reversed, false) = false + AND t.transaction_date <= ? + ORDER BY t.transaction_date DESC, t.id DESC + LIMIT 1) AS balance_on_date + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_savings_product p ON p.id = sa.product_id + WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?) + """ + typeFilter + """ + ORDER BY sa.deposit_type_enum, sa.account_no + """, date, date); + + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + final int depositType = num(r.get("deposit_type_enum")); + m.put("accountId", r.get("id")); + m.put("accountNo", r.get("account_no")); + m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS"); + m.put("clientName", r.get("client_name")); + m.put("productName", r.get("product_name")); + m.put("status", savingsStatus(num(r.get("status_enum")))); + m.put("openedOn", str(r.get("activatedon_date"))); + m.put("closedOn", str(r.get("closedon_date"))); + m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn)); + m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn)); + // No transaction up to that date means the account had nothing in it yet, not that the + // balance is unknown. + m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date")); + m.put("currentBalance", r.get("account_balance_derived") == null + ? BigDecimal.ZERO : r.get("account_balance_derived")); + out.add(m); + } + return out; + } + + /** + * Share accounts. Their transactions carry no running balance the way savings ones do, but they + * are individually dated, so the holding on a given date is the shares purchased up to that + * date less those redeemed — which is a real dated figure, not the current one relabelled. + */ + private List> shareAccounts(final LocalDate asOn) { + final Date date = Date.valueOf(asOn); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date, + sa.total_approved_shares, + c.display_name AS client_name, + p.name AS product_name, + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price + WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price) + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600)) AS share_value, + -- Purchases less redemptions dated on or before the register's date. Charge + -- rows (type 700) are excluded: an entry fee is not share capital. Only + -- approved purchases count (status 300) — a rejected application leaves its + -- row behind still flagged active, and counting it credited a member with + -- shares they were never issued. + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price + WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price) + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600) + AND t.transaction_date <= ?) AS value_on_date, + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares + WHEN t.type_enum = 600 THEN -t.total_shares + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600) + AND t.transaction_date <= ?) AS shares_on_date + FROM m_share_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_share_product p ON p.id = sa.product_id + WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?) + ORDER BY sa.account_no + """, date, date, date); + + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("accountId", r.get("id")); + m.put("accountNo", r.get("account_no")); + m.put("section", "SHARE"); + m.put("clientName", r.get("client_name")); + m.put("productName", r.get("product_name")); + m.put("status", shareStatus(num(r.get("status_enum")))); + m.put("openedOn", str(r.get("activated_date"))); + m.put("closedOn", str(r.get("closed_date"))); + m.put("openedOnDate", sameDay(r.get("activated_date"), asOn)); + m.put("closedOnDate", sameDay(r.get("closed_date"), asOn)); + m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date")); + m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value")); + // Shares as at the date, not the approved figure — an account can hold fewer than were + // approved once some have been redeemed. + m.put("sharesHeld", r.get("shares_on_date")); + out.add(m); + } + return out; + } + + private static boolean sameDay(final Object dbDate, final LocalDate asOn) { + return dbDate != null && asOn.toString().equals(dbDate.toString()); + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } + + private static int num(final Object o) { + return o instanceof Number n ? n.intValue() : 0; + } + + private static String savingsStatus(final int status) { + return switch (status) { + case 100 -> "SUBMITTED"; + case 200 -> "APPROVED"; + case 300 -> "ACTIVE"; + case 400 -> "WITHDRAWN"; + case 500 -> "REJECTED"; + case 600 -> "CLOSED"; + case 700 -> "TRANSFER IN PROGRESS"; + case 800 -> "TRANSFER ON HOLD"; + case 900 -> "PREMATURELY CLOSED"; + default -> "UNKNOWN"; + }; + } + + private static String shareStatus(final int status) { + return switch (status) { + case 100 -> "SUBMITTED"; + case 200 -> "APPROVED"; + case 300 -> "ACTIVE"; + case 400 -> "WITHDRAWN"; + case 500 -> "REJECTED"; + case 600 -> "CLOSED"; + default -> "UNKNOWN"; + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java index 6792f9e6b..014ba7d8a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java @@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** @@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Slf4j -@Transactional +// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not +// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer. +@Transactional(propagation = Propagation.REQUIRES_NEW) public class AmlFundTransferEvaluationService { private static final int STRUCTURING_WINDOW_DAYS = 30; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 862213e83..843a436d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +/** + * REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a + * transaction that has already succeeded. Joining the caller's transaction would let a failed + * alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at + * commit time - and the callers' catch blocks cannot rescue that, because the damage is done to + * the shared transaction rather than thrown to them. A private transaction keeps AML failure + * contained to AML. + */ @Service @RequiredArgsConstructor -@Transactional +@Transactional(propagation = Propagation.REQUIRES_NEW) public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { private static final int MEDIUM_RISK = 2; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java new file mode 100644 index 000000000..ae56cd0b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -0,0 +1,477 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.branchday.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Branch day register — the formal opening and closing of a branch's business day. + * + * A day that was never closed is the thing this exists to surface: while it stays open its balance + * cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash + * difference on that date goes unnoticed. The register records who opened and closed each day, and + * lists the ones still hanging. + * + * Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it + * was left open, so "closed that evening" and "closed nine days later" stay distinguishable. + */ +@Path("/v1/branchday") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class BranchDayRegisterApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ── GET /v1/branchday ── the register itself ────────────────────────────── + + /** + * The register for one office, newest first. Defaults to the caller's own office so a branch + * user sees their own branch without having to know its id. + */ + @GET + public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT r.id, r.office_id, o.name AS office_name, r.business_date, + r.opened_by_name, r.opened_at, r.opening_cash, + r.closed_by_name, r.closed_at, r.closing_cash, + r.book_cash, r.cash_difference, + r.force_closed, r.force_close_reason, r.remarks + FROM m_branch_day_register r + LEFT JOIN m_office o ON o.id = r.office_id + WHERE r.office_id = ? + ORDER BY r.business_date DESC + LIMIT ? + """, officeId, limit); + + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("canOperate", canOperateDay(currentUser)); + result.put("days", mapDays(rows)); + result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); + result.put("openDay", findOpenDay(officeId)); + return gson.toJson(result); + } + + // ── GET /v1/branchday/missed ── days left open ──────────────────────────── + + /** + * Business days that were opened and never closed, oldest first — the ones that need attention + * most. Today's own open day is excluded: it is open because the day is still running, which is + * not a missed close. + */ + @GET + @Path("missed") + public String missed(@QueryParam("officeId") final Long officeIdParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT r.id, r.office_id, o.name AS office_name, r.business_date, + r.opened_by_name, r.opened_at, r.opening_cash, + r.closed_by_name, r.closed_at, r.closing_cash, + r.book_cash, r.cash_difference, + r.force_closed, r.force_close_reason, r.remarks + FROM m_branch_day_register r + LEFT JOIN m_office o ON o.id = r.office_id + WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ? + ORDER BY r.business_date ASC + """, officeId, Date.valueOf(today)); + + final List> days = mapDays(rows); + // Age in days is what makes the list actionable — a day open since last week is a different + // problem from one open since yesterday. + for (final Map d : days) { + final Object bd = d.get("businessDate"); + d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today)); + } + + return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days, + "count", days.size())); + } + + // ── GET /v1/branchday/close-preview ────────────────────────────────────── + + /** + * What the day should close at, with the arithmetic that produces it, plus the denomination + * total the cashier actually keyed in during the day. + * + * Exists so nobody has to work the figure out by hand or type it from memory: the screen shows + * where every line came from, and offers the counted total as the closing figure. A day is + * still closed on what someone physically counted — this only removes the guesswork about what + * that count is supposed to match. + */ + @GET + @Path("close-preview") + public String closePreview(@QueryParam("officeId") final Long officeIdParam, + @QueryParam("businessDate") final String businessDateParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + + final LocalDate businessDate; + if (businessDateParam != null && !businessDateParam.isBlank()) { + try { + businessDate = LocalDate.parse(businessDateParam); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid", + "Business date must be in yyyy-MM-dd format."); + } + } else { + final Map open = findOpenDay(officeId); + if (open == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.not.open", + "No business day is open for this branch."); + } + businessDate = LocalDate.parse(String.valueOf(open.get("businessDate"))); + } + + final Map result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate)); + result.put("officeId", officeId); + result.put("businessDate", businessDate.toString()); + + // What the cashier's own denomination entries add up to for the day. Where this and the + // expected figure agree, the day tallies and the closing figure is not in question. + final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination + WHERE txn_date = ? AND txn_type = 'DEPOSIT' + """, BigDecimal.class, Date.valueOf(businessDate))); + final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination + WHERE txn_date = ? AND txn_type = 'WITHDRAWAL' + """, BigDecimal.class, Date.valueOf(businessDate))); + result.put("denominationIn", countedIn); + result.put("denominationOut", countedOut); + + return gson.toJson(result); + } + + // ── POST /v1/branchday/open ─────────────────────────────────────────────── + + @POST + @Path("open") + @Consumes(MediaType.APPLICATION_JSON) + public String openDay(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + requireDayAuthority(currentUser); + + final var root = this.fromJsonHelper.parse(body); + final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); + final LocalDate businessDate = extractDate(root, "businessDate", + LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); + + // A branch runs one day at a time. Opening a second while the first is still open is how a + // day gets stranded, so the previous one has to be closed first. + final Map open = findOpenDay(officeId); + if (open != null) { + final Object existing = open.get("businessDate"); + if (businessDate.toString().equals(String.valueOf(existing))) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.open", + "Business day " + businessDate + " is already open for this branch."); + } + throw new PlatformDataIntegrityException("error.msg.branchday.previous.open", + "Business day " + existing + " is still open. Close it before opening " + businessDate + "."); + } + + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + Integer.class, officeId, Date.valueOf(businessDate)); + if (exists != null && exists > 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.closed", + "Business day " + businessDate + " has already been opened and closed for this branch."); + } + + // Yesterday's closing is today's opening unless the caller states otherwise, so the carried + // figure does not have to be retyped and cannot drift. + final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate); + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_branch_day_register + (office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed) + VALUES (?,?,?,?,?,?,false) + RETURNING id + """, + Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(), + Timestamp.from(Instant.now()), carried); + + return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(), + "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN")); + } + + // ── POST /v1/branchday/close ────────────────────────────────────────────── + + @POST + @Path("close") + @Consumes(MediaType.APPLICATION_JSON) + public String closeDay(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + requireDayAuthority(currentUser); + + final var root = this.fromJsonHelper.parse(body); + final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); + final LocalDate businessDate = extractDate(root, "businessDate", null); + if (businessDate == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.required", + "Which business day is being closed?"); + } + final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); + + final List> found = jdbcTemplate.queryForList( + "SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + officeId, Date.valueOf(businessDate)); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.not.open", + "Business day " + businessDate + " was never opened for this branch."); + } + if (found.get(0).get("closed_at") != null) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.closed", + "Business day " + businessDate + " is already closed."); + } + + // Closing a past date is allowed — otherwise a day left open could never be settled at all — + // but it must say why it was left open, and it is stamped as a force close. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final boolean isLate = businessDate.isBefore(today); + if (isLate && (forceReason == null || forceReason.isBlank())) { + throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required", + "Business day " + businessDate + " is being closed late. Record why it was left open."); + } + + final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); + final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; + + jdbcTemplate.update(""" + UPDATE m_branch_day_register + SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?, + book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? + WHERE office_id = ? AND business_date = ? + """, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash, + bookCash, difference, isLate, isLate ? forceReason : null, remarks, + officeId, Date.valueOf(businessDate)); + + final Map res = new LinkedHashMap<>(); + res.put("officeId", officeId); + res.put("businessDate", businessDate.toString()); + res.put("status", "CLOSED"); + res.put("closingCash", closingCash); + res.put("bookCash", bookCash); + res.put("cashDifference", difference); + res.put("forceClosed", isLate); + return gson.toJson(res); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private List> mapDays(final List> rows) { + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("officeId", r.get("office_id")); + m.put("officeName", r.get("office_name")); + m.put("businessDate", str(r.get("business_date"))); + m.put("openedByName", r.get("opened_by_name")); + m.put("openedAt", str(r.get("opened_at"))); + m.put("openingCash", r.get("opening_cash")); + m.put("closedByName", r.get("closed_by_name")); + m.put("closedAt", str(r.get("closed_at"))); + m.put("closingCash", r.get("closing_cash")); + m.put("bookCash", r.get("book_cash")); + m.put("cashDifference", r.get("cash_difference")); + m.put("forceClosed", r.get("force_closed")); + m.put("forceCloseReason", r.get("force_close_reason")); + m.put("remarks", r.get("remarks")); + m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED"); + out.add(m); + } + return out; + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } + + /** The branch's currently open day, or null when none is open. */ + private Map findOpenDay(final Long officeId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash + FROM m_branch_day_register + WHERE office_id = ? AND closed_at IS NULL + ORDER BY business_date ASC LIMIT 1 + """, officeId); + if (rows.isEmpty()) { + return null; + } + final Map r = rows.get(0); + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("businessDate", str(r.get("business_date"))); + m.put("openedByName", r.get("opened_by_name")); + m.put("openedAt", str(r.get("opened_at"))); + m.put("openingCash", r.get("opening_cash")); + return m; + } + + /** The most recent closing figure before this date, so it carries forward as the opening. */ + private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) { + final List rows = jdbcTemplate.query(""" + SELECT closing_cash FROM m_branch_day_register + WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL + ORDER BY business_date DESC LIMIT 1 + """, (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before)); + return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0); + } + + /** + * Cash the books say the branch should be holding at the end of that date: what came in as + * approved cash deposits less what went out as approved cash withdrawals. Deliberately derived + * here and then stored on the row, so the figure the day was closed against survives any later + * reversal. + */ + private Map bookBreakdown(final Long officeId, final LocalDate businessDate) { + final Date date = Date.valueOf(businessDate); + + // The figure recorded when this very day was opened — not the previous day's closing. + // Deriving it from history alone ignored an opening balance keyed in by hand, which is + // exactly what happens on the first day a branch is put on the system: the day was opened + // with 20,000 in the drawer and the closing arithmetic started from zero. + final BigDecimal ownOpening = jdbcTemplate.query( + "SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); + final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); + + final BigDecimal deposits = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request + WHERE status = 'APPROVED' + AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? + AND (office_id = ? OR office_id IS NULL) + """, BigDecimal.class, date, officeId)); + + final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request + WHERE status = 'APPROVED' + AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? + """, BigDecimal.class, date)); + + // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. + final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer + WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, date)); + + // Cash a cashier settled back to the vault at the end of the day. + final BigDecimal settledOut = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement + WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, date)); + + final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut); + + final Map out = new LinkedHashMap<>(); + out.put("openingCash", opening); + out.put("deposits", deposits); + out.put("vaultIn", vaultIn); + out.put("withdrawals", withdrawals); + out.put("settledOut", settledOut); + out.put("expectedCash", expected); + return out; + } + + private static BigDecimal zero(final BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private Long resolveOffice(final Long requested, final AppUser user) { + if (requested != null) { + return requested; + } + return user.getOffice() != null ? user.getOffice().getId() : 1L; + } + + private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) { + final String raw = this.fromJsonHelper.extractStringNamed(field, root); + if (raw == null || raw.isBlank()) { + return fallback; + } + try { + return LocalDate.parse(raw); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid", + "Business date must be in yyyy-MM-dd format."); + } + } + + /** + * Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the + * same authority as the manager here by design — a day left open because the only manager was + * away is exactly the situation this register exists to prevent. + * + * A cashier is excluded: the cashier works inside the day and must not be the one who declares + * it closed. + */ + private boolean canOperateDay(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk")); + } + + private void requireDayAuthority(final AppUser user) { + if (!canOperateDay(user)) { + throw new PlatformDataIntegrityException("error.msg.branchday.forbidden", + "Only a Clerk, Head Cashier or Bank Manager can open or close a business day."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index f0cb31fce..95e444476 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -32,6 +32,179 @@ public class CashReportApiResource { private final CashierRoleHelper cashierRoleHelper; private final Gson gson = new Gson(); + /** + * Day book — every voucher raised on one date, in one list, newest first. + * + * Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR) + * and the clerk disbursement register (DR, or CR for a collection). Each row carries its + * voucher number, side, amount, account, party and status, so the cashier can reconcile a day + * without opening three separate screens. + * + * Dated on the business date the voucher belongs to rather than the row's creation timestamp: + * deposits and withdrawals carry an explicit txn_date, and the disbursement register — which + * has no such column — falls back to the date it was submitted on. + */ + @GET + @Path("day-book") + public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side, + @QueryParam("status") final String status) { + + final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + + final List> rows = new ArrayList<>(); + + // Each register is left-joined to the verification table and to an attachment count, so a + // row already carries its physical-check verdict and whether a slip was scanned in — the + // screen never has to fire a follow-up call per voucher. + final String verifyJoin = """ + LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = r.voucher_no + """; + final String verifyCols = """ + vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks, + vv.verified_by_name, vv.verified_at, + vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at, + COALESCE(va.n, 0) AS attachment_count + """; + + // Cashier deposits — money in, so the credit side. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type, + r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, + r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_cashier_deposit_request r + """ + verifyJoin + """ + WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Clerk withdrawals — money out, so the debit side. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type, + r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, + r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_withdrawal_request r + """ + verifyJoin + """ + WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a + // bank transfer moves it out (DR) — matching how the voucher itself was numbered. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, + CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side, + r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type, + r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name, + r.description, r.status, r.clerk_name AS handled_by, + r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_disbursement_request r + """ + verifyJoin + """ + WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be + // ordered as one result set, and the row count for a single day is small. + final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); + final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase(); + + final List> filtered = new ArrayList<>(); + BigDecimal creditTotal = BigDecimal.ZERO; + BigDecimal debitTotal = BigDecimal.ZERO; + + for (final Map row : rows) { + if (sideFilter != null && !sideFilter.equals(row.get("side"))) { + continue; + } + if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) { + continue; + } + filtered.add(row); + + // Only approved money is real money: a pending or rejected voucher must not inflate the + // day's totals, even though it still belongs in the listing. + if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) { + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount != null) { + if ("CR".equals(row.get("side"))) { + creditTotal = creditTotal.add(amount); + } else { + debitTotal = debitTotal.add(amount); + } + } + } + } + + filtered.sort((a, b) -> { + final Object left = b.get("submittedAt"); + final Object right = a.get("submittedAt"); + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.toString().compareTo(right.toString()); + }); + + final Map result = new LinkedHashMap<>(); + result.put("date", txnDate); + result.put("rows", filtered); + result.put("count", filtered.size()); + result.put("creditTotal", creditTotal); + result.put("debitTotal", debitTotal); + result.put("netTotal", creditTotal.subtract(debitTotal)); + return gson.toJson(result); + } + + private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { + final Map row = new LinkedHashMap<>(); + row.put("voucherNo", rs.getString("voucher_no")); + row.put("side", rs.getString("side")); + row.put("kind", rs.getString("kind")); + row.put("accountNo", rs.getString("account_no")); + row.put("accountType", rs.getString("account_type")); + row.put("party", rs.getString("party")); + row.put("amount", rs.getBigDecimal("amount")); + row.put("paymentTypeName", rs.getString("payment_type_name")); + row.put("description", rs.getString("description")); + row.put("status", rs.getString("status")); + row.put("handledBy", rs.getString("handled_by")); + row.put("approvedBy", rs.getString("approved_by")); + row.put("txnId", rs.getString("txn_id")); + + // Physical-slip check. verifyStatus is null until someone actually looks at the paper. + row.put("verifyStatus", rs.getString("verify_status")); + row.put("slipAmount", rs.getBigDecimal("slip_amount")); + row.put("verifyRemarks", rs.getString("verify_remarks")); + row.put("verifiedByName", rs.getString("verified_by_name")); + final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at"); + row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString()); + + // How the exception ended, if it has. Without this the day book keeps showing a closed + // exception as though it were still open — the flag never clears on screen. + row.put("resolutionStatus", rs.getString("resolution_status")); + row.put("resolutionNotes", rs.getString("resolution_notes")); + row.put("resolvedByName", rs.getString("resolved_by_name")); + final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at"); + row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString()); + row.put("attachmentCount", rs.getInt("attachment_count")); + // Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped + // form the browser cannot parse reliably. + final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at"); + final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at"); + row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString()); + row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString()); + return row; + } + @GET @Path("closing") public String closing(@QueryParam("date") String date) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java new file mode 100644 index 000000000..4db4f4227 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -0,0 +1,754 @@ +package org.apache.fineract.portfolio.cashtransfer.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Share Advance Collection API — cash taken from a member BEFORE any share account exists. + * + * The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that + * flow. + * + *

+ * 1. Collect — {@code POST /v1/shareadvancecollection} + * + *

+ *   Dr  Cash at Vault      (SHARES_REFERENCE)  = amount
+ *   Cr  Share Sundry       (SHARES_SUSPENSE)   = amount
+ * 
+ * + * The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in + * {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet. + * + *

+ * 2. Apply — {@code POST /v1/shareadvancecollection/{id}/apply} + * + *

+ *   Dr  Share Sundry       (SHARES_SUSPENSE)   = everything collected
+ *   Cr  Share Capital      (SHARES_EQUITY)     = share amount
+ *   Cr  Income from Fees   (INCOME_FROM_FEES)  = charge amount  (only when a charge applies)
+ *   Cr  Savings            (via deposit cmd)   = over-payment    (only when the member paid more)
+ * 
+ * + * Applying clears the liability, issues the share capital, marks the account's charges paid and sets + * {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views. + * Account creation, approval and activation post no GL of their own — all share accounting happens here. + * + *

+ * When the cash does not match + * + *

+ * A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be + * argued with at the window: + * + *

    + *
  • Short — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt + * and applies the receipts together: one settlement, several receipts, all marked applied.
  • + *
  • Over — allowed, and the difference is credited to the member's linked savings account through Fineract's + * own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute + * back to them.
  • + *
+ */ +@Path("/v1/shareadvancecollection") +@Component +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareAdvanceCollectionApiResource { + + // ── acc_gl_journal_entry.type_enum ───────────────────────────────────── + // Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr). + private static final int DEBIT = 2; + private static final int CREDIT = 1; + + // ── CashAccountsForShares financial_account_type ─────────────────────── + private static final int FA_SHARES_REFERENCE = 1; + private static final int FA_SHARES_SUSPENSE = 2; + private static final int FA_INCOME_FROM_FEES = 3; + private static final int FA_SHARES_EQUITY = 4; + + // ── acc_product_mapping.product_type for shares ──────────────────────── + private static final int PRODUCT_TYPE_SHARES = 4; + + // ── m_share_account.status_enum ─────────────────────────────────────── + private static final int STATUS_ACTIVE = 300; + + // ── GL account the cashier deposits share money into ────────────────── + private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new Gson(); + + // ══════════════════════════════════════════════════════════════════════ + // POST — Take cash from a member with no share account yet + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + public Response collect(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final Long clientId = getLong(json, "clientId"); + final BigDecimal amount = getBigDecimal(json, "amount"); + final String note = sanitiseNote(getString(json, "note")); + final LocalDate entryDate = parseDate(getString(json, "transactionDate")); + + if (clientId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required."); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero."); + } + + final Map client = loadClient(clientId); + final Long officeId = toLong(client.get("office_id")); + final String clientName = (String) client.get("display_name"); + + // The share product drives the GL mapping. Collection happens before an account exists, + // so the product is supplied by the caller or defaults to the only mapped share product. + final Long productId = resolveProductId(getLong(json, "productId")); + final String currencyCode = resolveCurrency(productId); + + final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)"); + final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHADV-" + System.nanoTime(); + final String receiptNo = nextReceiptNo(); + + final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : ""); + + insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc); + insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode, + "Share advance held in Sundry | Receipt " + receiptNo); + + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId, + currentUser.getUsername(), note); + + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class, + receiptNo); + + final Map out = new LinkedHashMap<>(); + out.put("collectionId", id); + out.put("receiptNo", receiptNo); + out.put("clientId", clientId); + out.put("clientName", clientName); + out.put("amount", amount); + out.put("currency", currencyCode); + out.put("status", "UNAPPLIED"); + out.put("glTransactionId", txnId); + out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created."); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — Unapplied receipts, optionally for one client + // ══════════════════════════════════════════════════════════════════════ + @GET + public Response listUnapplied(@QueryParam("clientId") final Long clientId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," + + " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note " + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "); + if (clientId != null) { + sql.append(" AND c.client_id = ").append(clientId); + } + sql.append(" ORDER BY c.collection_date, c.id"); + + final List> rows = jdbcTemplate.queryForList(sql.toString()); + final Map out = new LinkedHashMap<>(); + out.put("unappliedCollections", rows); + out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — One unapplied receipt by the voucher number quoted by the member + // ══════════════════════════════════════════════════════════════════════ + // The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001: + // as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict + // firewall with a 400 before the request reaches this resource. The older + // /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes. + @GET + @Path("/by-voucher") + public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo, + @QueryParam("clientId") final Long clientIdHint) { + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required", + "A voucher or receipt number is required."); + } + return findByVoucher(voucherNo, clientIdHint); + } + + @GET + @Path("/by-voucher/{voucherNo}") + public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) { + this.context.authenticatedUser(); + + // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. + final List> rows = jdbcTemplate + .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + + " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id " + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); + + if (rows.isEmpty()) { + // Cash is normally taken through the cashier's Transaction Entry deposit into the Share + // Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a + // collection so the member's voucher resolves here. + final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint); + if (adopted != null) { + return Response.ok(gson.toJson(adopted)).build(); + } + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher " + + voucherNo + ". Check the number, or confirm the deposit has been approved by a manager."); + } + + final Map row = rows.get(0); + if (!"UNAPPLIED".equals(row.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again."); + } + return Response.ok(gson.toJson(row)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST — Apply a receipt to an activated share account + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{collectionId}/apply") + @Transactional + public Response apply(@PathParam("collectionId") final Long collectionId, final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final Long shareAccountId = getLong(json, "shareAccountId"); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required."); + } + + // A member who pays in instalments has more than one receipt against the same application: + // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they + // are applied together as one settlement. The path receipt leads; any others come in the + // body. + final List collectionIds = new java.util.ArrayList<>(); + collectionIds.add(collectionId); + for (final Long additionalId : getLongList(json, "additionalCollectionIds")) { + if (!collectionIds.contains(additionalId)) { + collectionIds.add(additionalId); + } + } + + final List> collections = new java.util.ArrayList<>(); + for (final Long id : collectionIds) { + final Map row = loadCollection(id); + if (!"UNAPPLIED".equals(row.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied", + "Receipt " + row.get("receipt_no") + " is already " + row.get("status") + "."); + } + collections.add(row); + } + + final Map coll = collections.get(0); + + final Map acct = loadAccount(shareAccountId); + + // Every receipt belongs to the member: applying one to someone else's account would move + // one member's money into another's share capital. Checked per receipt, since a settlement + // can now carry several. + final Long acctClientId = toLong(acct.get("client_id")); + for (final Map row : collections) { + if (!toLong(row.get("client_id")).equals(acctClientId)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no") + + " belongs to a different member than share account " + acct.get("account_no") + "."); + } + } + + final int statusEnum = toInt(acct.get("status_enum")); + if (statusEnum != STATUS_ACTIVE) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active", + "Share account " + acct.get("account_no") + " must be Active before a collection can be applied."); + } + if (Boolean.TRUE.equals(acct.get("collection_applied"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled", + "Share account " + acct.get("account_no") + " has already been settled."); + } + + // Share value the account was opened for, and any entry fee attached to it. + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + + BigDecimal receiptAmount = BigDecimal.ZERO; + final StringBuilder receiptNos = new StringBuilder(); + for (final Map row : collections) { + receiptAmount = receiptAmount.add((BigDecimal) row.get("amount")); + if (receiptNos.length() > 0) { + receiptNos.append(" + "); + } + receiptNos.append(row.get("receipt_no")); + } + + // Short is blocked: share capital cannot be part-issued — the member either bought those + // shares or did not — so settling for less would leave the account short by an amount owed + // by nobody. The clerk collects the balance on another receipt and applies both together. + if (receiptAmount.compareTo(required) < 0) { + final BigDecimal shortfall = required.subtract(receiptAmount); + throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short", + "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires " + + required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall + + " from the member, then apply both receipts together."); + } + + // Over-payment is allowed through rather than turning a member away over a rounded-up note. + // The difference is credited to their linked savings account below. + final BigDecimal excessAmount = receiptAmount.subtract(required); + final Long savingsAccountId = toLong(acct.get("savings_account_id")); + if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings", + "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + + " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings " + + "account, but none is linked to this share account. Link one, or collect the exact amount."); + } + + final Long officeId = toLong(acct.get("office_id")); + final Long productId = toLong(acct.get("product_id")); + final String currencyCode = (String) acct.get("currency_code"); + + final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate entryDate = parseDate(getString(json, "transactionDate")); + final String txnId = "SHAPP-" + System.nanoTime(); + final String accountNo = (String) acct.get("account_no"); + + // Dr Sundry for everything collected, including any over-payment — the liability those + // receipts created is cleared in full, in one leg, so nothing is left behind in suspense. + insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); + + // Cr Share Capital for the share value. + insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share capital issued for " + accountNo); + + // Cr Income from Fees for the entry fee, when one was charged. + if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + // Resolved from the share product's "Income from Fees" mapping, which points at the + // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + + jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); + } + + // The over-payment goes back to the member as a savings deposit rather than a bare journal + // entry, so it lands in their passbook and running balance the way any counter deposit + // would. Posted through Fineract's own deposit command, which writes its own Cr Savings + // Control leg — the Dr side is the Sundry debit above, which covered the whole receipt. + Long excessSavingsTransactionId = null; + if (excessAmount.compareTo(BigDecimal.ZERO) > 0) { + excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate, + "Excess on share application receipt " + receiptNos + " for share account " + accountNo); + } + + // Stamp the originating cashier deposits so the same vouchers cannot be applied twice. + for (final Map row : collections) { + if (row.get("voucher_no") != null) { + jdbcTemplate.update( + "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " + + " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO); + } + } + + // Every receipt in this settlement is marked applied, not just the one in the path — they + // were all consumed by it, and an unmarked one would still show as money awaiting use. + for (final Long id : collectionIds) { + jdbcTemplate.update( + "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); + } + + // Reveals the purchase transaction in the passbook and transactions views. + jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); + + final Map out = new LinkedHashMap<>(); + out.put("collectionId", collectionId); + out.put("collectionIds", collectionIds); + out.put("receiptNo", receiptNos.toString()); + out.put("shareAccountId", shareAccountId); + out.put("accountNo", accountNo); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalApplied", required); + out.put("totalCollected", receiptAmount); + out.put("glTransactionId", txnId); + out.put("status", "APPLIED"); + // Told at the counter, while the member is still there, rather than left to be noticed on + // a statement later. + out.put("excessAmount", excessAmount); + if (excessSavingsTransactionId != null) { + out.put("excessSavingsTransactionId", excessSavingsTransactionId); + out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to " + + "their savings account."); + } + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** + * Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection. + * + * The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager, + * so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup + * so the voucher resolves and can be applied. + * + * A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the + * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which + * is what keeps the money tied to the right member. + * + * @return the newly created collection row, or null when no approved Sundry deposit matches. + */ + private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) { + final List> deposits = jdbcTemplate + .queryForList( + "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," + + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + + " FROM m_cashier_deposit_request d" + + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", + voucherNo, SHARE_SUNDRY_ACCOUNT_NO); + + if (deposits.isEmpty()) { + return null; + } + final Map dep = deposits.get(0); + + if (toLong(dep.get("share_account_id")) != null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " has already been applied to a share account."); + } + + // Prefer the member recorded on the deposit; fall back to the account being settled. + final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint; + if (clientId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client", + "Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member," + + " so it cannot be matched. Re-enter the deposit against the member."); + } + + final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId); + final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR"; + final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final String receiptNo = nextReceiptNo(); + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency, + dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), + "Adopted from cashier deposit voucher " + voucherNo); + + return jdbcTemplate.queryForMap( + "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", + receiptNo); + } + + private Long loadClientOfficeId(final Long clientId) { + return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId); + } + + /** Share value the account was opened for: the pending/approved purchase transaction. */ + private BigDecimal shareValueOf(final Long shareAccountId) { + final BigDecimal total = jdbcTemplate + .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " + + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + /** Entry fee still outstanding on the account, zero when no charge was attached. */ + private BigDecimal outstandingChargeOf(final Long shareAccountId) { + // Driven by the money still owed rather than the is_paid_derived flag alone: accounts + // created before the flat-charge fix carry the flag set to true while the full amount is + // still outstanding, and those charges must still be settled by the collection. + final BigDecimal total = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge " + + " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0", + BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + private Map loadCollection(final Long id) { + try { + return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", + "Share advance collection " + id + " does not exist."); + } + } + + private Map loadClient(final Long clientId) { + try { + return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist."); + } + } + + private Map loadAccount(final Long shareAccountId) { + try { + return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, " + + " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id " + + " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found", + "Share account " + shareAccountId + " does not exist."); + } + } + + /** + * Uses the caller's product when given, else the single mapped share product. + * + * The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are + * not share products (stale rows left by deleted products), and counting those would make auto-detection fail even + * when only one real share product is mapped. + */ + private Long resolveProductId(final Long requested) { + if (requested != null) { + return requested; + } + final List ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m " + + " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES); + if (ids.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped", + "No share product has GL mappings configured."); + } + if (ids.size() > 1) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required", + "productId is required: " + ids.size() + " share products have GL mappings."); + } + return ids.get(0); + } + + private String resolveCurrency(final Long productId) { + try { + final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class, + productId); + return code == null ? "INR" : code; + } catch (EmptyResultDataAccessException e) { + return "INR"; + } + } + + private Long requireGl(final Long productId, final int financialAccountType, final String label) { + final Long glId = resolveGl(productId, financialAccountType); + if (glId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } + + private Long resolveGl(final Long productId, final int financialAccountType) { + try { + return jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + } catch (IncorrectResultSizeDataAccessException e) { + return null; + } + } + + private String nextReceiptNo() { + final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class); + return String.format("SHADV%06d", seq == null ? 1 : seq); + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in + * the tenant's own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required."); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (Exception e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON."); + } + } + + private LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + } + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid", + "transactionDate must be in YYYY-MM-DD format. Received: " + raw); + } + } + + private String sanitiseNote(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + final String trimmed = raw.trim(); + return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed; + } + + private String getString(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } + + private Long getLong(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; + } + + private BigDecimal getBigDecimal(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null; + } + + /** Further receipts making up one settlement. Empty when the caller sent a single receipt. */ + private List getLongList(final JsonObject json, final String field) { + final List values = new java.util.ArrayList<>(); + if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) { + return values; + } + for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) { + if (!element.isJsonNull()) { + values.add(element.getAsLong()); + } + } + return values; + } + + /** + * Credits an over-payment to the member's savings account. + * + * Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in + * the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry + * would move the balance in the ledger while leaving the member's own account history silent about it. + */ + private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()) + .build(); + return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId(); + } + + /** + * The payment type this deposit is recorded under. + * + * Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in + * the opposite direction — an internal transfer between a share application and the member's savings, not cash + * over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row. + */ + private Long resolveExcessPaymentTypeId() { + final List byName = jdbcTemplate.queryForList( + "SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class); + if (!byName.isEmpty()) { + return byName.get(0); + } + final List any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class); + if (any.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type", + "No payment type is configured, so the over-payment cannot be credited to the member's savings account."); + } + return any.get(0); + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } + + private int toInt(final Object o) { + return o instanceof Number ? ((Number) o).intValue() : 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java index 5e7d9c977..bca22f22d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java @@ -375,8 +375,20 @@ public class ShareCollectionApiResource { userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), - now.atOffset(ZoneOffset.UTC), - now.atOffset(ZoneOffset.UTC)); + toUtc(now), + toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in + * the tenant's own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); } private Map glEntryMap(final String account, final String type, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java new file mode 100644 index 000000000..f80c7fc50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java @@ -0,0 +1,601 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.cashtransfer.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Settles a share application out of the member's own savings account. + * + * The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks + * in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks + * with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly: + * + *
+ *   Dr  Savings Control     (share value + entry fee)
+ *       Cr  Share Capital       (share value)
+ *       Cr  Entry Fee Income    (charge, when one is attached)
+ * 
+ * + * No Sundry leg, because nothing is ever held — the debit and the share issue happen together. + * + * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval + * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair + * of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves. + * + * The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and + * running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own + * Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and + * replaced with the share-capital pair above. + */ +@Path("/v1/sharesavingsdebit") +@Component +@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareSavingsDebitRequestApiResource { + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int CREDIT = 1; + private static final int DEBIT = 2; + + // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource. + private static final int FA_INCOME_FROM_FEES = 3; + private static final int FA_SHARES_EQUITY = 4; + private static final int PRODUCT_TYPE_SHARES = 4; + + /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ + private static final int PRODUCT_TYPE_SAVINGS = 2; + private static final int FA_SAVINGS_CONTROL = 2; + + private static final int SHARE_STATUS_ACTIVE = 300; + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** m_payment_type.value seeded by changelog 0483 for this settlement. */ + private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); + + // ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ────────────── + + /** + * Whether this share account can be settled from savings, and for how much. + * + * Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked, + * does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the + * counter instead of after a request has been raised and has to be rejected. + */ + @GET + @Path("/eligibility/{shareAccountId}") + public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + final Map acct = loadShareAccount(shareAccountId); + + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + + final Map out = new LinkedHashMap<>(); + out.put("shareAccountId", shareAccountId); + out.put("shareAccountNo", acct.get("account_no")); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalRequired", required); + + final Object savingsAccountIdRaw = acct.get("savings_account_id"); + if (savingsAccountIdRaw == null) { + out.put("eligible", false); + out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead."); + return Response.ok(gson.toJson(out)).build(); + } + final Long savingsAccountId = toLong(savingsAccountIdRaw); + final Map savings = loadSavingsAccount(savingsAccountId); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + final BigDecimal available = balance == null ? BigDecimal.ZERO : balance; + + out.put("savingsAccountId", savingsAccountId); + out.put("savingsAccountNo", savings.get("account_no")); + out.put("savingsBalance", available); + + final String blocker = settlementBlocker(acct, savings, required, available); + out.put("eligible", blocker == null); + out.put("reason", blocker); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Why this share account cannot be settled from savings right now, or null when it can. + * + * Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement + * is allowed. + */ + private String settlementBlocker(final Map shareAccount, final Map savings, + final BigDecimal required, final BigDecimal available) { + + if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) { + return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled."; + } + if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) { + return "Share account " + shareAccount.get("account_no") + " has already been settled."; + } + if (required.compareTo(BigDecimal.ZERO) <= 0) { + return "There is nothing outstanding on share account " + shareAccount.get("account_no") + "."; + } + // The savings account must belong to the same member — debiting anyone else's balance for + // these shares would be taking one member's money for another's holding. + if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) { + return "The linked savings account belongs to a different member."; + } + if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { + return "Savings account " + savings.get("account_no") + " is not Active."; + } + if (available.compareTo(required) < 0) { + return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required + + " is required. Take a deposit first, or settle by cash collection."; + } + return null; + } + + // ── POST /v1/sharesavingsdebit ── Clerk records the request ───────────── + + @POST + @Transactional + public Response submit(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = parseBody(body); + final Long shareAccountId = getLong(json, "shareAccountId"); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required."); + } + + final Map acct = loadShareAccount(shareAccountId); + final Object savingsAccountIdRaw = acct.get("savings_account_id"); + if (savingsAccountIdRaw == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings", + "Share account " + acct.get("account_no") + " has no linked savings account."); + } + final Long savingsAccountId = toLong(savingsAccountIdRaw); + final Map savings = loadSavingsAccount(savingsAccountId); + + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + + // Checked at submit as well as at approval: the clerk should be told now, not after the + // member has left and a manager finds the account short. + final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); + if (blocker != null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); + } + + final Long existingPending = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class, + shareAccountId); + if (existingPending != null && existingPending > 0) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending", + "A savings-debit request for this share account is already pending review."); + } + + final Long newId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_savings_debit_request + (share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount, + currency_code, status, clerk_id, clerk_name, note) + VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?) + RETURNING id + """, Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required, + acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note")); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", newId); + out.put("status", "PENDING"); + out.put("totalAmount", required); + return Response.ok(gson.toJson(out)).build(); + } + + // ── GET /v1/sharesavingsdebit ── Requests awaiting review ─────────────── + + @GET + public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status, + @QueryParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name, + r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance, + r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status, + r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at, + r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note + FROM m_share_savings_debit_request r + JOIN m_share_account sa ON sa.id = r.share_account_id + JOIN m_client c ON c.id = r.client_id + LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id + WHERE 1 = 1 + """); + final List params = new ArrayList<>(); + if (!"ALL".equalsIgnoreCase(status)) { + sql.append(" AND r.status = ? "); + params.add(status.toUpperCase()); + } + if (shareAccountId != null) { + sql.append(" AND r.share_account_id = ? "); + params.add(shareAccountId); + } + sql.append(" ORDER BY r.submitted_at DESC, r.id DESC"); + + return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build(); + } + + // ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ───────── + + /** + * Debits the member's savings and issues the shares. + * + * Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the + * approval is the point at which it actually matters. + */ + @POST + @Path("/{id}/approve") + @Transactional + public Response approve(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + validateCheckerRole(currentUser); + + final Map req = loadRequest(id); + if (!"PENDING".equals(req.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", + "Only PENDING requests can be approved. This one is " + req.get("status") + "."); + } + if (toLong(req.get("clerk_id")).equals(currentUser.getId())) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval", + "Maker-checker: you cannot approve a request you submitted yourself. Ask another manager."); + } + + final Long shareAccountId = toLong(req.get("share_account_id")); + final Long savingsAccountId = toLong(req.get("savings_account_id")); + final Map acct = loadShareAccount(shareAccountId); + final Map savings = loadSavingsAccount(savingsAccountId); + + // Recomputed rather than taken from the request: shares or charges may have been edited + // between submit and approval, and the member must be debited for what is actually owed. + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + + final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); + if (blocker != null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); + } + + final Long productId = toLong(acct.get("product_id")); + final Long officeId = toLong(acct.get("office_id")); + final String currencyCode = (String) acct.get("currency_code"); + final String accountNo = (String) acct.get("account_no"); + + final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String note = "Share application settled from savings | " + accountNo; + + // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL + // pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money + // became share capital, which the pair below records. + final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHSAV-" + savingsTxnId; + + insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application debited from savings " + savings.get("account_no") + " | " + accountNo); + insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share capital issued for " + accountNo); + + if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); + } + + // Marks the account settled, which is what stops it being settled again by either route. + this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); + + this.jdbcTemplate.update(""" + UPDATE m_share_savings_debit_request + SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), + share_amount = ?, charge_amount = ?, total_amount = ?, + savings_transaction_id = ?, gl_transaction_id = ? + WHERE id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("status", "APPROVED"); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalDebited", required); + out.put("savingsTransactionId", savingsTxnId); + return Response.ok(gson.toJson(out)).build(); + } + + // ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ─────────── + + @POST + @Path("/{id}/reject") + @Transactional + public Response reject(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map req = loadRequest(id); + if (!"PENDING".equals(req.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", + "Only PENDING requests can be rejected. This one is " + req.get("status") + "."); + } + + final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body); + this.jdbcTemplate.update(""" + UPDATE m_share_savings_debit_request + SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? + WHERE id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("status", "REJECTED"); + return Response.ok(gson.toJson(out)).build(); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, + final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId(); + } + + /** + * The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the + * withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out. + * The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it + * on the member's statement and in payment-type reporting. + * + * Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles + * rather than failing at approval with money already half-moved. + */ + private Long resolveSettlementPaymentTypeId() { + final List> rows = this.jdbcTemplate + .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE); + if (!rows.isEmpty()) { + return toLong(rows.get(0).get("id")); + } + final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1"); + if (fallback.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type", + "No payment types are configured. Add one before settling a share application from savings."); + } + return toLong(fallback.get(0).get("id")); + } + + /** Savings Control GL for this savings account's product. */ + private Long savingsControlGlOf(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT apm.gl_account_id FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 + """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); + return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); + } + + /** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */ + private BigDecimal shareValueOf(final Long shareAccountId) { + final BigDecimal total = this.jdbcTemplate + .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " + + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + /** Entry fee still outstanding, zero when no charge was attached. */ + private BigDecimal outstandingChargeOf(final Long shareAccountId) { + final BigDecimal total = this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge " + + " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0", + BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + private Map loadShareAccount(final Long shareAccountId) { + try { + return this.jdbcTemplate.queryForMap(""" + SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, + sa.collection_applied, sa.savings_account_id, c.office_id + FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ? + """, shareAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found", + "Share account " + shareAccountId + " does not exist."); + } + } + + private Map loadSavingsAccount(final Long savingsAccountId) { + try { + return this.jdbcTemplate.queryForMap( + "SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", + savingsAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found", + "Savings account " + savingsAccountId + " does not exist."); + } + } + + private Map loadRequest(final Long id) { + try { + return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found", + "Savings-debit request " + id + " does not exist."); + } + } + + private Long requireGl(final Long productId, final int financialAccountType, final String label) { + try { + final Long glId = this.jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + if (glId == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } catch (final IncorrectResultSizeDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + this.jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's + * own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } + + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required", + "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request."); + } + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required."); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (final RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON."); + } + } + + private Long getLong(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; + } + + private String getString(final JsonObject json, final String field) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return null; + } + final String value = json.get(field).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private Long toLong(final Object o) { + return o instanceof Number n ? n.longValue() : null; + } + + private int toInt(final Object o) { + return o instanceof Number n ? n.intValue() : 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index bbea5abf1..53913d2da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource { activityLog, Timestamp.from(Instant.now()), requestType ); + // A collection pulls money into the GL, so it reads as a credit; a disbursement and a + // bank transfer both move money out of the source side, so they read as a debit. + final String voucherPrefix = "COLLECTION".equals(requestType) + ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT; final String voucherNo = voucherService.createVoucher( VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST, - newId, txnAmount, currentUser); + newId, txnAmount, currentUser, voucherPrefix); jdbcTemplate.update( "UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId); @@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource { return ps; }, btLogKh); final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); - final String btVoucherNo = generateBtVoucherNo(btLogId); + final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId); } @@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource { return ps; }, logKh); final long logId = Objects.requireNonNull(logKh.getKey()).longValue(); - final String voucherNo = generateBtVoucherNo(logId); + final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId); final JsonObject res = new JsonObject(); @@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource { * Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even * under concurrent inserts on the same date. */ - private String generateBtVoucherNo(final long logId) { - return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd")) - + String.format("%05d", logId); + /** + * Bank-transfer settlement voucher, drawn from the same central register as every other + * voucher so the two numbering schemes cannot drift apart. A settlement moves money out of + * the debited accounts, so it reads as a debit: DR/001/2108/0001. + */ + private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { + return voucherService.createVoucher( + VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG, + logId, amount, currentUser, VoucherService.PREFIX_DEBIT); } // ── Helpers ─────────────────────────────────────────────────────────────────── 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 index 8a51e1603..fc2bad879 100644 --- 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 @@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; -import java.math.RoundingMode; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; @@ -58,36 +57,29 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Maker-checker workflow for a member to claim an unclaimed dividend (see - * {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability - * that was never credited into any savings account). A clerk (maker) records exactly how the - * member wants it split when they visit the branch: straight into an overdue loan, credited to - * their linked savings account, or handed over as cash -- any combination, any leftover amount - * stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes - * the real disbursement, or rejects it, leaving the unclaimed liability untouched. + * Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status + * {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk + * (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the + * draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a + * later visit, and is forfeited to profit if three years pass. * *

- * Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native - * repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct), - * which debits whatever GL account is actually configured as that product's Fund Source -- not - * necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own - * "Warehouse Receipt" account instead). Since the money never actually came from that account, - * that same DEBIT leg is rewritten in place to Dividend Payable (see - * {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual - * entry pair, so the transaction ends up with exactly the entries a real accountant would expect - * -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched -- - * instead of that fund-source account confusingly appearing twice. The cash leg has no account to - * post through, so approval - * posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the - * physical hand-over is deferred: the claim's {@code cash_status} becomes - * {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be - * redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before - * the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s - * submit/approve/disburse token flow for ordinary savings withdrawals. + * A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and + * loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member + * holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no + * counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on + * those historical rows — but new claims only ever write {@code dd_apply_amount}. + * + *

+ * The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD + * number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter + * debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the + * member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue + * request, and why no charge or GST attaches to it. */ @Path("/v1/clerk/dividend-claim-requests") @Component -@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.") +@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.") @RequiredArgsConstructor public class DividendClaimRequestApiResource { @@ -98,44 +90,27 @@ public class DividendClaimRequestApiResource { private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101; + /** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */ + private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + + /** + * Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the + * member — so the register shows who actually raised it rather than repeating the beneficiary's name in both + * columns, which would read as a counter sale to the member. + */ + private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)"; private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name", "r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id", - "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", - "r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", - "l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", - "r.submitted_at", "r.actioned_at" }; - - // ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ────────── - - /** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */ - @GET - @Path("/overdue-loans/{clientId}") - @Produces(MediaType.APPLICATION_JSON) - public String overdueLoans(@PathParam("clientId") final Long clientId) { - final List> 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); - } + "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount", + "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id", + "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at", + // The Demand Draft this claim is collected as — the only settlement route for new claims. + // loan/savings/cash columns above remain for claims settled before those routes were retired. + "r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id", + "r.dd_number" }; // ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ── @@ -147,18 +122,10 @@ public class DividendClaimRequestApiResource { final AppUser currentUser = context.authenticatedUser(); final JsonElement root = fromJsonHelper.parse(body); final Long lockId = fromJsonHelper.extractLongNamed("lockId", root); - final Long loanId = fromJsonHelper.extractLongNamed("loanId", root); - final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root); - final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root)); - final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root)); - // Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), - // while a dividend share often works out to paise -- floor to the nearest rupee (never - // trusting the frontend to have already done so) so the denomination breakdown the - // cashier counts at disburse time can always match exactly; any leftover paise is simply - // not claimed here and stays part of the lock's balance for a future claim. - final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root)) - .setScale(0, RoundingMode.FLOOR); - final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); + final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root)); + final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root)); + final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root)); + final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root)); if (lockId == null) { throw apiError(400, "lockId is required"); } @@ -171,53 +138,28 @@ public class DividendClaimRequestApiResource { } final Long clientId = ((Number) lock.get("client_id")).longValue(); final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount"); - final Object lockSavingsAccountId = lock.get("savings_account_id"); - // The share account isn't required to have a linked savings account up front any more -- - // if the member wants a savings credit and none is linked, the clerk supplies which - // account to credit right here. - Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue() - : savingsAccountIdOverride; - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (resolvedSavingsAccountId == null) { - throw apiError(400, - "This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero"); - } - final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, - resolvedSavingsAccountId); - if (savingsClientId == null || !clientId.equals(savingsClientId)) { - throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client"); - } + if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(400, "ddApplyAmount is required and must be greater than zero"); + } + if (ddApplyAmount.compareTo(unclaimedAmount) > 0) { + throw apiError(400, + "Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount); + } + if (ddBeneficiaryName == null) { + throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee"); + } + if (ddPayableAtBranch == null || ddPayableAtCity == null) { + throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft"); } - final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount); - if (total.compareTo(BigDecimal.ZERO) <= 0) { - throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero"); - } - if (total.compareTo(unclaimedAmount) > 0) { - throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount); - } - - if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (loanId == null) { - throw apiError(400, "loanId is required when loanApplyAmount is greater than zero"); - } - final Map loan = jdbcTemplate - .queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst() - .orElseThrow(() -> apiError(404, "Loan not found: " + loanId)); - if (!clientId.equals(((Number) loan.get("client_id")).longValue())) { - throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client"); - } - if (((Number) loan.get("loan_status_id")).intValue() != 300) { - throw apiError(400, "Loan " + loanId + " is not Active"); - } - } - - if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (tokNo == null || tokNo.isBlank()) { - throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero"); - } - validateTokenUniqueToday(tokNo, null); + // A draft can only be raised if there is stock to raise it on. Checked here rather than at + // approval so the clerk finds out while the member is still at the counter, instead of the + // claim sitting pending and failing later. + final Long availableLeaflets = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class); + if (availableLeaflets == null || availableLeaflets == 0) { + throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."); } final Long existingPending = jdbcTemplate.queryForObject( @@ -226,40 +168,85 @@ public class DividendClaimRequestApiResource { throw apiError(409, "A claim request for this dividend is already pending review."); } + validateNotGuarantorOfOverdueLoan(clientId); + + // loan/savings/cash apply amounts are written as zero: those routes are retired, but the + // columns remain for claims settled through them historically. final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_share_dividend_claim_request - (lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount, - cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?) + (lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount, + dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city, + status, clerk_id, clerk_name, submitted_at) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?) RETURNING id - """, Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount, - cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), - cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null); + """, Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } /** - * Ensures the token number is unique for today's date across both this table and - * {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same - * Transaction Entry field, so the two pools must never collide. + * Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan. + * + * A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security + * behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed + * loan is in arrears hands over money the society may be about to demand back from the very same member, and once + * a draft is drawn there is nothing left to recover it from. + * + * Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has + * not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted + * on. The block lifts on its own the moment the borrower clears the arrears. + * + * Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell + * the member standing at the counter why — and so the member can go to the borrower about it. */ - private void validateTokenUniqueToday(final String tokNo, final Long excludeId) { - final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request " - + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'"); - final List params = new ArrayList<>(); - params.add(tokNo); - if (excludeId != null) { - sql.append(" AND id <> ?"); - params.add(excludeId); + private void validateNotGuarantorOfOverdueLoan(final Long clientId) { + final List> overdueGuarantees = jdbcTemplate.queryForList(""" + SELECT l.account_no, + COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name, + COALESCE(SUM(ri.principal_amount + ri.interest_amount + - COALESCE(ri.principal_completed_derived, 0) + - COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount + FROM m_guarantor g + JOIN m_loan l ON l.id = g.loan_id + LEFT JOIN m_client b ON b.id = l.client_id + JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id + WHERE g.entity_id = ? + AND g.type_enum = 1 + AND g.is_active = true + AND l.loan_status_id IN (300, 303, 304, 700) + AND ri.completed_derived = false + AND ri.duedate < CURRENT_DATE + GROUP BY l.account_no, b.display_name + HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount + - COALESCE(ri.principal_completed_derived, 0) + - COALESCE(ri.interest_completed_derived, 0)), 0) > 0 + ORDER BY overdue_amount DESC + """, clientId); + + if (overdueGuarantees.isEmpty()) { + return; } - final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()); - final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " - + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo); - final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount); - if (total > 0) { - throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number."); + + final StringBuilder detail = new StringBuilder(); + for (final Map guarantee : overdueGuarantees) { + if (detail.length() > 0) { + detail.append("; "); + } + detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name")) + .append(") overdue ").append(guarantee.get("overdue_amount")); } + + throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: " + + detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends."); + } + + private String trimToNull(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; } private BigDecimal parseAmount(final String value) { @@ -317,373 +304,207 @@ public class DividendClaimRequestApiResource { if (!"PENDING".equals(record.get("status"))) { throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status")); } + // 409 rather than 403: the user has every right to approve dividend claims — just not this + // one, because they raised it. A 403 reads as "you lack permission", which sends the checker + // to an administrator to fix access that was never the problem. final Long makerId = ((Number) record.get("clerk_id")).longValue(); if (makerId.equals(currentUser.getId())) { - throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager."); + throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a " + + "payout: one to raise it, another to check it. Ask a different manager to approve this claim."); } final Long lockId = ((Number) record.get("lock_id")).longValue(); final Map lock = jdbcTemplate - .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream() - .findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId)); + .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst() + .orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId)); if (!"UNCLAIMED".equals(lock.get("status"))) { throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")"); } + // Re-checked at approval, not just at submission: a claim can sit pending for days, and a + // guaranteed loan can fall into arrears in that window. Approval is the point the draft is + // actually drawn, so it is the last moment the society can still hold the money back. + validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue()); + final String currencyCode = (String) lock.get("currency_code"); final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount"); - final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount"); - final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount"); - final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount"); - final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount); - if (total.compareTo(unclaimedAmount) > 0) { + final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount"); + if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(409, "This claim has no Demand Draft amount recorded."); + } + if (ddApplyAmount.compareTo(unclaimedAmount) > 0) { throw apiError(409, "The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit."); } - final Object claimSavingsAccountId = record.get("savings_account_id"); - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) { - throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion."); - } final LocalDate today = DateUtils.getBusinessLocalDate(); - final String note = "Dividend claim #" + id + " (lock #" + lockId + ")"; + final Map issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today, + currentUser); - if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - final Long loanId = ((Number) record.get("loan_id")).longValue(); - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", today.toString()); - payload.addProperty("transactionAmount", loanApplyAmount); - payload.addProperty("note", note); - final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId) - .withJson(payload.toString()).build(); - final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand); - redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null); - } - - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", today.toString()); - payload.addProperty("transactionAmount", savingsApplyAmount); - payload.addProperty("note", note); - payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId()); - final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue(); - final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId) - .withJson(payload.toString()).build(); - final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand); - redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId()); - } - - if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today); - } - - final BigDecimal remainder = unclaimedAmount.subtract(total); + final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount); if (remainder.compareTo(BigDecimal.ZERO) > 0) { - jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, - lockId); + jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId); } else { jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId); } - final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null; jdbcTemplate.update(""" UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?, - cash_status = ? + dd_issue_request_id = ?, dd_number = ? WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id); + """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), + issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id); final JsonObject response = new JsonObject(); response.addProperty("resourceId", id); response.addProperty("status", "APPROVED"); - response.addProperty("totalDisbursed", total); + response.addProperty("totalDisbursed", ddApplyAmount); response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO)); + response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber")); + response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId")); return gson.toJson(response); } - // ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ── - /** - * Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction - * Entry types the clerk-supplied token number here to resolve the approved cash amount before - * disbursing it, instead of the manager handing over cash directly at approval time. + * Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable + * liability rather than by anything the member pays in. + * + * A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they + * are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable + * and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those + * columns are written as zero. + * + * The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a + * real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other. */ - @GET - @Path("/by-token/{token}") - @Produces(MediaType.APPLICATION_JSON) - public String getByToken(@PathParam("token") final String token) { - final List> rows = jdbcTemplate.queryForList(""" - SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount, - r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at - FROM m_share_dividend_claim_request r - JOIN m_client c ON c.id = r.client_id - WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE - ORDER BY r.submitted_at DESC LIMIT 1 - """, token); + private Map issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId, + final Map record, final BigDecimal amount, final String currencyCode, final LocalDate today, + final AppUser currentUser) { - if (rows.isEmpty()) { - throw apiError(404, "Token '" + token + "' not found for today. Please check the token number."); - } + // Oldest series first, and locked so two concurrent approvals cannot take the same leaflet. + final Map leaflet = jdbcTemplate.queryForList(""" + SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' + ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED + """).stream().findFirst() + .orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory.")); + final Long leafletId = ((Number) leaflet.get("id")).longValue(); + final String ddNumber = (String) leaflet.get("dd_number"); - final Map row = rows.get(0); - final String status = String.valueOf(row.get("status")); - final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status")); + final Long clientId = ((Number) record.get("client_id")).longValue(); + final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); - if ("PENDING".equals(status)) { - throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet."); - } - if ("REJECTED".equals(status)) { - throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed."); - } - if (!"APPROVED".equals(status)) { - throw apiError(422, "Token '" + token + "' has unexpected status: " + status); - } - if ("DISBURSED".equals(cashStatus)) { - throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier."); - } - if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) { - throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement."); - } - return gson.toJson(row); - } + // APPROVED outright: the manager approving this claim IS the check on it, and a second + // maker-checker round on the draft would leave a member's paid dividend in limbo. + // client_id still records WHOSE dividend this is — the entitlement belongs to them and the + // draft has to be traceable back to the member. walkin_purchaser_name names who actually + // raised it, which is the society, so the two roles stay distinguishable. + final Long issueRequestId = jdbcTemplate.queryForObject(""" + INSERT INTO m_sponsor_dd_issue_request + (leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city, + face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, + payment_method, status, maker_id, created_date, checker_id, checker_date) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?) + RETURNING id + """, Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), + record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today), + ((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(), + Timestamp.from(Instant.now())); - // ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ── + // The leaflet carries its own copy of the draft's details, and the DD register reads from + // there rather than from the issue request — so setting the status alone leaves the draft + // listed with every column blank. Mirrors what the ordinary issue flow writes. + // + // issued_to_client_id is deliberately left NULL and the purchaser named as the society + // instead. On an ordinary draft the client IS the purchaser — they walked in and bought it. + // Here the society is paying the member their own dividend, so recording the member as + // purchaser would read as though they had bought the draft, and the register would show the + // same name as both purchaser and beneficiary with nothing to distinguish a + // society-initiated draft from a counter sale. + jdbcTemplate.update(""" + UPDATE m_sponsor_dd_leaflet + SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL, + walkin_purchaser_name = ?, beneficiary_name = ?, + payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?, + advice_status = 'NOT_SENT' + WHERE id = ? + """, amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"), + record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId); - /** - * Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at - * approve time, so this step is purely a physical-cash-handover record with double-disbursement - * protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction. - */ - @POST - @Path("/{id}/disburse") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Transactional - public String disburse(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = context.authenticatedUser(); - final Map record = jdbcTemplate - .queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?", - id) - .stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id)); + postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser); - final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status")); - if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) { - throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus); - } - - String denominationData = null; - BigDecimal transactionAmount = null; - if (body != null && !body.isBlank()) { - final JsonElement root = fromJsonHelper.parse(body); - denominationData = fromJsonHelper.extractStringNamed("denominationData", root); - transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root)); - } - if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) { - throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement."); - } - - // cash_apply_amount is floored to whole rupees at submit time now (see submit() above), - // but a claim created before that fix may still carry paise -- floor here too so an older - // claim can still be disbursed in whole-rupee cash rather than being stuck forever. - final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount"); - final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR); - if (disbursableAmount.compareTo(transactionAmount) != 0) { - throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of " - + disbursableAmount + " (cash can only be handed over in whole rupees)."); - } - - final long denomTotal = denominationTotal(denominationData); - if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) { - throw apiError(400, - "Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount); - } - - final int updated = jdbcTemplate.update(""" - UPDATE m_share_dividend_claim_request SET - cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ? - WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT' - """, currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id); - - if (updated == 0) { - throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented."); - } - - if (denominationData != null && !denominationData.isBlank()) { - final Long clientId = ((Number) record.get("client_id")).longValue(); - final Long lockId = ((Number) record.get("lock_id")).longValue(); - saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser); - } - - return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED")); - } - - /** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */ - private long denominationTotal(final String denominationData) { - if (denominationData == null || denominationData.isBlank()) { - return 0; - } - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (final Exception e) { - throw apiError(400, "denominationData is not valid JSON"); - } - if (denomMap == null) { - return 0; - } - long total = 0; - for (final Map.Entry entry : denomMap.entrySet()) { - final String[] parts = entry.getKey().split(":", 2); - if (parts.length != 2) { - continue; - } - final int value; - final int count; - try { - value = Integer.parseInt(parts[1]); - count = entry.getValue().getAsInt(); - } catch (final Exception e) { - continue; - } - if (count > 0) { - total += (long) value * count; - } - } - return total; + final Map result = new java.util.LinkedHashMap<>(); + result.put("issueRequestId", issueRequestId); + result.put("ddNumber", ddNumber); + return result; } /** - * Records the cashier's note/coin breakdown for a dividend cash disbursement into - * {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings - * transaction backing this (the cash leg is a pure GL entry posted at approve time), so this - * claim request's own id is used as the stable txn identifier for grouping. + * Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but + * now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and + * paid, through the normal DD settlement. + * + * Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration + * and forfeiture entries take, so a missing GL mapping never traps a member's dividend. */ - private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId, - final String denominationData, final AppUser cashier) { - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (final Exception e) { - return; - } - if (denomMap == null || denomMap.size() == 0) { - return; - } + private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode, + final BigDecimal amount, final LocalDate today, final AppUser currentUser) { - for (final Map.Entry entry : denomMap.entrySet()) { - final int count; - try { - count = entry.getValue().getAsInt(); - } catch (final Exception e) { - continue; - } - if (count <= 0) { - continue; - } - - final String[] parts = entry.getKey().split(":", 2); - if (parts.length != 2) { - continue; - } - final String kind = parts[0]; - final int value; - try { - value = Integer.parseInt(parts[1]); - } catch (final Exception e) { - continue; - } - - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1", - Long.class, value, kind); - if (ids.isEmpty()) { - continue; - } - final Long denomId = ids.get(0); - - jdbcTemplate.update(""" - INSERT INTO m_savings_txn_denomination - (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?) - """, claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count, - BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername()); - } - } - - /** - * Fineract's savings deposit command mandates paymentTypeId even for an internal posting with - * no real customer payment instrument -- resolves the system-seeded "Dividend Claim - * Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which - * would break on any tenant seeded differently. - */ - private Long resolveDividendClaimPaymentTypeId() { - final Long paymentTypeId = jdbcTemplate - .queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream() + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + final Long dividendPaidAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_DIVIDEND_PAID) + .stream().findFirst().orElse(null); + final Long ddPayableAccountId = jdbcTemplate + .queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream() .findFirst().orElse(null); - if (paymentTypeId == null) { - throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations."); - } - return paymentTypeId; - } - - /** - * The loan repayment / savings deposit command just posted its own native GL entries assuming - * the money came out of whichever account is that product's actual Fund Source (not always - * literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its - * own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with - * a second manual entry pair (which would leave that fund-source account appearing twice, once - * debited then immediately credited back -- a confusing "wash" no real accountant would post), - * this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction - * ends up with exactly the entries a real accountant would expect: Dividend Payable debited, - * Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still - * updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of - * the two ids is non-null per call. - */ - private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) { - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null) { - return; - } - if (loanTransactionId != null) { - jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?", - payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); - } - if (savingsTransactionId != null) { - jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?", - payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); - } - } - - /** - * A straight cash handover has no account to post through, so Cash at Main Vault genuinely is - * correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the - * member's hand. - */ - private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount, - final LocalDate entryDate) { - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - final Long cashAtVaultAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null || cashAtVaultAccountId == null) { + if (payableDividendsAccountId == null || ddPayableAccountId == null) { return; } - final AppUser currentUser = context.authenticatedUser(); - final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); - final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime(); - final String description = "Dividend claim #" + claimRequestId + " disbursed"; + final java.sql.Date entryDate = java.sql.Date.valueOf(today); + final String transactionId = "DIVDD" + claimId; + // Stamped with the financial year the dividend was DECLARED for, not the year it is being + // claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot + // say how much of each year's declaration has actually been claimed. + final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #" + + claimId + " (lock #" + lockId + ")"; + + if (dividendPaidAccountId == null) { + // Dividend Paid not mapped: post straight across, exactly as before this account existed. + // A society that has not configured it still gets a correct payout, just without the + // separate "how much has been claimed" measure. + postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description, + currentUser); + return; + } + + // Routed through Dividend Paid rather than posted straight across, so the society can read + // off what has been claimed and what is still unclaimed as two separate balances instead of + // only the net of Dividend Payable. + // + // Two pairs, in the order the money moves, each with its own description — reading Dividend + // Paid's ledger has to show which line settled the member's entitlement and which raised the + // draft, and one shared description makes the two indistinguishable. + postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate, + description + " (member's entitlement settled)", currentUser); + // The obligation does not end here: the society still owes the money until the draft is + // presented and paid, which is why this moves to Sponsor DD Payable rather than to cash. + // Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed. + postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, + description + " (draft raised)", currentUser); + } + + /** + * Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction + * the money travelled and the ledger reads in order. + */ + private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, + final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description, + final AppUser currentUser) { + final String insertSql = """ INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, @@ -691,13 +512,12 @@ public class DividendClaimRequestApiResource { submitted_on_date, transaction_date) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; - this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount, + description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); + jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount, + description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); } - // ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ─── @POST @Path("/{id}/reject") @@ -742,6 +562,7 @@ public class DividendClaimRequestApiResource { private Map 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, + dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city, status, clerk_id, clerk_name FROM m_share_dividend_claim_request WHERE id = ? """, id); @@ -751,6 +572,28 @@ public class DividendClaimRequestApiResource { return rows.get(0); } + /** + * The financial year of the declaration a lock came from — walking lock to per-member detail to declaration. + * + * Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being + * paid their dividend. + */ + private String financialYearOfLock(final Long lockId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.dividend_period_end_date + FROM m_share_dividend_lock l + JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + WHERE l.id = ? + """, lockId); + if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) { + return "unknown"; + } + final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date"))); + final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + private WebApplicationException apiError(final int httpStatus, final String message) { final JsonObject errorBody = new JsonObject(); errorBody.addProperty("message", message); 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 index 89e7a78bc..43be84903 100644 --- 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 @@ -51,11 +51,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout - * progress, full 3-year lock history, and members whose unclaimed dividend (see - * {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can - * proactively apply it towards that EMI instead of waiting for the member to claim it or for it - * to be forfeited to profit after 3 years. + * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year + * lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan + * installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or + * for it to be forfeited to profit after 3 years. */ @Path("/v1/clerk/dividend-dashboard") @Component @@ -112,8 +111,7 @@ public class DividendDashboardApiResource { final JsonObject result = new JsonObject(); result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue()); - result.addProperty("pendingApprovalDeclarations", - ((Number) declarationStats.get("pending_approval_declarations")).longValue()); + result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue()); result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue()); result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue()); result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue()); @@ -132,9 +130,9 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/declarations ──────────────────────────── /** - * Every dividend ever declared (across every share product), so a manager tracking multiple - * dividends at once can see -- per declaration -- how many members it covers and how far its - * payout has actually progressed, not just whether it was approved. + * Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can + * see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just + * whether it was approved. */ @GET @Path("/declarations") @@ -179,25 +177,34 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/locks ─────────────────────────────────── /** - * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still - * counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan -- - * see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's - * profit). Optional {@code status} query param filters to one. + * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a + * member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3 + * years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one. */ @GET @Path("/locks") @Produces(MediaType.APPLICATION_JSON) public String locks(@QueryParam("status") final String status) { final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status); + // Which declaration each lock came from. Once a member has more than one unclaimed dividend — + // this year's plus a prior year they never collected — amount and locked date alone cannot + // tell them apart, so the clerk has no way to say which year is being paid out. The join + // walks lock -> per-member detail -> declaration -> product to name the period and product. final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, " + "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, " - + "l.applied_loan_id, ln.account_no AS applied_loan_account_no " // + + "l.applied_loan_id, ln.account_no AS applied_loan_account_no, " + + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " // + "FROM m_share_dividend_lock l " // + "JOIN m_client c ON c.id = l.client_id " // + "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " // + "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " // + + "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " // + + "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " // + + "LEFT JOIN m_share_product p ON p.id = d.product_id " // + (filterByStatus ? "WHERE l.status = ? " : "") // - + "ORDER BY l.locked_date DESC"; + // Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that + // needs attention, and it is the one a member should be paid out of first. + + "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC"; final List> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase()) : jdbcTemplate.queryForList(sql); @@ -217,6 +224,17 @@ public class DividendDashboardApiResource { final Object appliedLoanId = row.get("applied_loan_id"); entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue()); entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no")); + final Object declarationId = row.get("declaration_id"); + entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue()); + entry.addProperty("productName", (String) row.get("product_name")); + final Object periodStart = row.get("dividend_period_start_date"); + final Object periodEnd = row.get("dividend_period_end_date"); + entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString()); + entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString()); + // The financial year the dividend was earned in, taken from the period end so a + // declaration is labelled by the year it closes — "FY 2026-27", the way a member and an + // auditor both refer to it. + entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString())); result.add(entry); } return gson.toJson(result); @@ -260,17 +278,19 @@ public class DividendDashboardApiResource { } private JsonArray overdueLoansFor(final Long clientId) { - final List> loans = jdbcTemplate.queryForList(""" - SELECT ln.id AS loan_id, ln.account_no, - SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0) - - COALESCE(ri.principal_writtenoff_derived, 0), 0) - + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0) - - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount - FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id - WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE - GROUP BY ln.id, ln.account_no - ORDER BY overdue_amount DESC - """, clientId); + final List> 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(); @@ -285,14 +305,13 @@ public class DividendDashboardApiResource { // ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ──────────────── /** - * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue - * loan EMI (see {@code list()} above) and applies it without waiting for the member to visit - * and file a claim. Since the dividend was never credited into any savings account (it's a - * pure "Dividend Payable" GL liability until claimed -- see - * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to - * make: this posts a native loan repayment (so the loan's own schedule/state stays fully - * native and correct) and then redirects the debit leg it assumed the cash came from to the - * Dividend Payable liability instead -- identical mechanic to a claim's loan leg. + * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see + * {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend + * was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see + * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a + * native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the + * debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a + * claim's loan leg. */ @POST @Path("/{lockId}/apply-to-loan") @@ -341,15 +360,14 @@ public class DividendDashboardApiResource { payload.addProperty("transactionDate", today.toString()); payload.addProperty("transactionAmount", applyAmount); payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")"); - final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId) - .withJson(payload.toString()).build(); + final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString()) + .build(); final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand); redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId()); final BigDecimal remainder = unclaimedAmount.subtract(applyAmount); if (remainder.compareTo(BigDecimal.ZERO) > 0) { - jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, - lockId); + jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId); } else { jdbcTemplate.update(""" UPDATE m_share_dividend_lock @@ -366,16 +384,14 @@ public class DividendDashboardApiResource { } /** - * The loan repayment just posted above assumed the money came out of whichever account is - * this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a - * Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead) - * -- it didn't really. Rather than reverse that with a second manual entry pair (which would - * leave that fund-source account appearing twice, once debited then immediately credited back - * -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's - * account in place to Dividend Payable, so the transaction ends up with exactly the entries a - * real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited, - * nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend - * Payable financial activity. + * The loan repayment just posted above assumed the money came out of whichever account is this loan product's + * actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product + * debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second + * manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately + * credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account + * in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect: + * Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if + * the bank hasn't mapped the Dividend Payable financial activity. */ private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) { final Long payableDividendsAccountId = jdbcTemplate @@ -389,6 +405,16 @@ public class DividendDashboardApiResource { payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); } + /** + * The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending + * 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it. + */ + private String financialYearOf(final String isoDate) { + final LocalDate date = LocalDate.parse(isoDate); + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + private WebApplicationException apiError(final int httpStatus, final String message) { final JsonObject errorBody = new JsonObject(); errorBody.addProperty("message", message); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java index ffcccd5c2..a64e785b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java @@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData { minimumActivePeriodFrequencyTypeOption, currenMarketPrice); } + /** + * Whether the member's money has actually been taken and the shares issued — either by applying a cash collection + * or by debiting their savings. + * + * Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and + * the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved, + * which is what the Transactions, Passbook and Charges views actually describe. + */ + private Boolean collectionApplied; + public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) { this.currentMarketPrice = currentMarketPrice; } + public void setCollectionApplied(final Boolean collectionApplied) { + this.collectionApplied = collectionApplied; + } + public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) { this.linkedLoanId = linkedLoanId; this.linkedLoanAccountNo = linkedLoanAccountNo; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java index 79953b34d..70f6dd8fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java @@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom { this.amountOrPercentage = chargeAmount; if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) { this.percentage = null; - this.amount = BigDecimal.ZERO; + // A flat charge is owed in full the moment it is attached: its amount IS the + // configured fee, and all of it is outstanding until something pays it. Leaving + // both at zero (as this once did) made the entry fee invisible to every consumer + // that reads amount/amount_outstanding_derived — the share-collection settlement + // then asked the member for the share value alone and the fee went uncollected. + this.amount = chargeAmount; this.amountPercentageAppliedTo = null; this.amountPaid = null; - this.amountOutstanding = BigDecimal.ZERO; + this.amountOutstanding = calculateOutstanding(); this.amountWaived = null; this.amountWrittenOff = null; } else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) { 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 index 37e77ca5f..d37112a9c 100644 --- 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 @@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.jdbc.core.JdbcTemplate; /** - * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year - * release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is - * forfeited — the hold on the member's savings account is released and the amount is swept back - * into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for - * the actual GL/balance mechanics. + * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with + * no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is + * released and the amount is swept back into the bank's profit. See + * {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics. */ @Slf4j @RequiredArgsConstructor @@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet { @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { final List exceptions = new ArrayList<>(); + // 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure + // "Dividend Payable" GL liability and renamed the initial status accordingly (see + // makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on + // 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently + // selected zero rows and nothing was ever forfeited. final List lockIds = this.jdbcTemplate.queryForList( - "SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class); + "SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class); for (final Long lockId : lockIds) { try { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 0e257bcf5..1e1c7dcc1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -654,18 +654,25 @@ public class ShareAccountDataSerializer { } private void handlechargesOnActivation(final ShareAccount account) { - Set charges = account.getCharges(); - for (ShareAccountCharge charge : charges) { - if (charge.isActive() && charge.isShareAccountActivation()) { - charge.markAsFullyPaid(); - } - } - + // The entry fee is deliberately NOT marked paid here. Activating the account decides the + // member may hold shares; it does not collect anything, and nobody has yet said where the + // money comes from — a cash receipt at the counter, or a debit from the member's savings. + // + // Marking it paid at activation left amount_paid_derived at the full fee and + // amount_outstanding_derived at zero while nothing had actually been received, so the + // settlement step read the fee as already settled and asked the member for the share value + // alone ("shares 400.00 + charges 0"). The fee was then never collected at all. + // + // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource + // (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set + // amount_paid_derived and amount_outstanding_derived alongside the flag. + // + // The charge transaction is still dated to the activation date — that records when the fee + // became due, which is a separate thing from when it was paid. Set transactions = account.getChargeTransactions(); for (ShareAccountTransaction transaction : transactions) { if (transaction.isChargeTransaction()) { transaction.updateTransactionDate(account.getActivatedDate()); - transaction.updateAmountPaid(transaction.amount()); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index 038249f89..a77facb3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false); final BigDecimal currentMarketPrice = deriveMarketPrice(productData); data.setCurrentMarketPrice(currentMarketPrice); + // Read separately rather than added to the row mapper, which is shared with the list and + // template queries that have no use for it. + data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account " + + " WHERE id = ?", Boolean.class, id)); if (!includeTemplate) { Collection dividends = this.retrieveAssociatedDividends(id); data.setDividends(dividends); 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 ffc9f7f8a..30d621458 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 @@ -40,8 +40,13 @@ import org.springframework.transaction.annotation.Transactional; public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService { private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400; + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + private final ShareAccountDividendRepository shareAccountDividendRepository; private final SavingsAccountDomainService savingsAccountDomainService; private final SavingsAccountAssembler savingsAccountAssembler; @@ -62,12 +67,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe } /** - * Real-world UCB accounting: an approved dividend is never credited into a member's savings - * account up front -- it stays purely a "Dividend Payable" GL liability (see - * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the - * member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed - * dividend past its 3-year deadline has no savings hold to release and no account balance to - * deduct -- the liability is simply written off directly against the bank's profit. + * Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it + * stays purely a "Dividend Payable" GL liability (see + * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually + * claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no + * savings hold to release and no account balance to deduct -- the liability is simply written off directly against + * the bank's profit. */ @Override @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -84,22 +89,55 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe postForfeitureJournalEntry(lockId, currencyCode, amount); - this.jdbcTemplate.update( - "UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); } /** - * Debit writes off the Dividend Payable liability (it was never going to be paid); credit - * increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off. - * Reads currency straight off the lock row (set from the share account at creation time) -- - * this never depended on a linked savings account existing. Skipped if the bank hasn't mapped - * both GL accounts yet, exactly like the declaration-time Reserve entry. + * The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is + * broken — a missing label must not stop the forfeiture from posting. + */ + private String financialYearOfLock(final Long lockId) { + final java.util.List> rows = this.jdbcTemplate.queryForList(""" + SELECT d.dividend_period_end_date + FROM m_share_dividend_lock l + JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + WHERE l.id = ? + """, lockId); + if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) { + return "unknown"; + } + final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date"))); + final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** + * Writes off a dividend nobody came to claim. + * + *
+     *   Dr  Dividend Payable            — the obligation ends; it was never going to be paid
+     *       Cr  Dividend Forfeited Income
+     * 
+ * + * Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and + * loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every + * other income account, which is the same equity account the dividend was declared out of — so the money completes + * its round trip, by way of the P&L. + * + * Currency comes straight off the lock row (set from the share account when the lock was created), so this never + * depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the + * declaration-time entries. */ private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) { final Long payableDividendsAccountId = this.jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) .stream().findFirst().orElse(null); + // Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank + // Profit along with every other income account — so the money still ends up back in the + // equity account the dividend was declared out of, but by way of the P&L, where a forfeited + // dividend is visible as that year's income rather than appearing in equity with no trace. final Long forfeitedIncomeAccountId = this.jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME) @@ -112,7 +150,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe final LocalDate entryDate = DateUtils.getBusinessLocalDate(); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); final String transactionId = "DIVFORFEIT" + lockId; - final String description = "Dividend forfeited to profit — share dividend lock #" + lockId; + // Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in — + // a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each + // year's declaration went unclaimed and came back to profit. + final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId; final String insertSql = """ INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, @@ -120,10 +161,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe submitted_on_date, transaction_date) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; - this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + // Debit first, then credit, so the entry ids follow the direction the money travelled. + this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, + amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT, + amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 6377c494e..1c7f1307f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1,522 +1,528 @@ -/** - * 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.service; - -import jakarta.persistence.PersistenceException; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; -import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; -import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; -import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; -import org.apache.fineract.infrastructure.core.exception.ErrorHandler; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent; -import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent; -import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.portfolio.account.service.AccountNumberGenerator; -import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; -import org.apache.fineract.portfolio.note.domain.Note; -import org.apache.fineract.portfolio.note.domain.NoteRepository; -import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction; -import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; -import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; -import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.orm.jpa.JpaSystemException; - -@RequiredArgsConstructor -public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService { - - private final ShareAccountDataSerializer accountDataSerializer; - - private final ShareAccountRepositoryWrapper shareAccountRepository; - - private final ShareProductRepositoryWrapper shareProductRepository; - - private final AccountNumberGenerator accountNumberGenerator; - - private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; - - private final JournalEntryWritePlatformService journalEntryWritePlatformService; - - private final NoteRepository noteRepository; - - private final JdbcTemplate jdbcTemplate; - - private final BusinessEventNotifierService businessEventNotifierService; - - @Override - public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) { - try { - ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand); - this.shareAccountRepository.saveAndFlush(account); - generateAccountNumber(account); - // GL entries are intentionally NOT posted at submission time. - // Cashier collects cash and enters GL directly in the transaction entry - // workstation: DR Cash → CR Share Sundry GL (share amount) and - // DR Cash → CR Charges GL (charge amount). The approval step posts the - // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. - - businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account)); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(account.getId()) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } catch (final PersistenceException dve) { - Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); - handleDataIntegrityIssues(jsonCommand, throwable, dve); - return CommandProcessingResult.empty(); - } - } - - private void generateAccountNumber(final ShareAccount account) { - if (account.isAccountNumberRequiresAutoGeneration()) { - final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES); - account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat)); - this.shareAccountRepository.save(account); - } - } - - private Map populateJournalEntries(final ShareAccount account, final Set transactions) { - final Map accountingBridgeData = new HashMap<>(); - Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2; - accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting); - accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE); - accountingBridgeData.put("shareAccountId", account.getId()); - accountingBridgeData.put("shareProductId", account.getShareProduct().getId()); - accountingBridgeData.put("officeId", account.getOfficeId()); - accountingBridgeData.put("currencyCode", account.getCurrency().getCode()); - final List> newTransactionsMap = new ArrayList<>(); - accountingBridgeData.put("newTransactions", newTransactionsMap); - - for (ShareAccountTransaction transaction : transactions) { - final Map transactionDto = new HashMap<>(); - transactionDto.put("officeId", account.getOfficeId()); - transactionDto.put("id", transaction.getId()); - transactionDto.put("date", transaction.getPurchasedDate()); - final Integer status = transaction.getTransactionStatus(); - final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null); - final Integer type = transaction.getTransactionType(); - final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null); - transactionDto.put("status", statusEnum); - transactionDto.put("type", typeEnum); - if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) { - BigDecimal amount = transaction.amount(); - if (transaction.chargeAmount() != null) { - amount = amount.add(transaction.chargeAmount()); - } - transactionDto.put("amount", amount); - } else { - transactionDto.put("amount", transaction.amount()); - } - - transactionDto.put("chargeAmount", transaction.chargeAmount()); - transactionDto.put("paymentTypeId", null); // FIXME::make it cash - // payment - if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) { - final List> chargesPaidData = new ArrayList<>(); - transactionDto.put("chargesPaid", chargesPaidData); - Set chargesPaidBySet = transaction.getChargesPaidBy(); - for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) { - Map chargesPaidDto = new HashMap<>(); - chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId()); - chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId()); - chargesPaidDto.put("amount", chargesPaidBy.getAmount()); - chargesPaidData.add(chargesPaidDto); - } - } - newTransactionsMap.add(transactionDto); - } - return accountingBridgeData; - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - } - // since we are reverting all journal entries we need to add journal - // entries for application request - if (changes.containsKey("reversalIds")) { - ArrayList reversalIds = (ArrayList) changes.get("reversalIds"); - this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); - changes.remove("reversalIds"); - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } catch (final PersistenceException dve) { - Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); - handleDataIntegrityIssues(jsonCommand, throwable, dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account); - ShareAccountTransaction transaction = null; - if (!changes.isEmpty()) { - this.shareAccountRepository.saveAndFlush(account); - transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname); - transaction = account.getShareAccountTransaction(transaction); - if (transaction != null) { - changes.clear(); - changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId()); - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - } - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - Set transactions = account.getShareAccountTransactions(); - Set journalTransactions = new HashSet<>(); - Long totalSubsribedShares = Long.valueOf(0); - - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && transaction.isPurchasTransaction()) { - journalTransactions.add(transaction); - totalSubsribedShares += transaction.getTotalShares(); - } - } - ShareProduct shareProduct = account.getShareProduct(); - shareProduct.addSubscribedShares(totalSubsribedShares); - this.shareProductRepository.save(shareProduct); - - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); - - businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - Set transactions = account.getShareAccountTransactions(); - Set journalTransactions = new HashSet<>(); - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && !transaction.isChargeTransaction()) { - journalTransactions.add(transaction); - } - } - - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - - Set transactions = account.getShareAccountTransactions(); - ArrayList journalEntryTransactions = new ArrayList<>(); - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && !transaction.isChargeTransaction()) { - journalEntryTransactions.add(transaction.getId()); - } - } - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - } - - // Cashier collected cash and entered GL directly via the transaction entry - // workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR - // Charges GL for charges). Mark charges as paid to reflect cashier's collection - // without posting duplicate GL entries — the approval step already posted the - // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. - jdbcTemplate.update( - "UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?", - accountId); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); - Long totalSubscribedShares = Long.valueOf(0); - if (transactionIds != null) { - Set transactions = new HashSet<>(); - for (Long id : transactionIds) { - ShareAccountTransaction transaction = account.retrievePurchasedShares(id); - transactions.add(transaction); - totalSubscribedShares += transaction.getTotalShares(); - } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - if (!totalSubscribedShares.equals(Long.valueOf(0))) { - ShareProduct shareProduct = account.getShareProduct(); - shareProduct.addSubscribedShares(totalSubscribedShares); - this.shareProductRepository.save(shareProduct); - } - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); - if (transactionIds != null) { - Set transactions = new HashSet<>(); - for (Long id : transactionIds) { - ShareAccountTransaction transaction = account.retrievePurchasedShares(id); - transactions.add(transaction); - } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.saveAndFlush(account); - ShareAccountTransaction transaction = (ShareAccountTransaction) changes - .get(ShareAccountApiConstants.requestedshares_paramname); - // after saving, entity will have different object. So need to - // retrieve the entity object - transaction = account.getShareAccountTransaction(transaction); - Long redeemShares = transaction.getTotalShares(); - ShareProduct shareProduct = account.getShareProduct(); - // remove the redeem shares from total subscribed shares - shareProduct.removeSubscribedShares(redeemShares); - this.shareProductRepository.saveAndFlush(shareProduct); - - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - changes.clear(); - changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); - - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account); - // Account status/closedDate are applied regardless of whether there was anything left to - // redeem — save and note-capture must not be gated on a transaction actually existing. - this.shareAccountRepository.saveAndFlush(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - this.noteRepository.save(note); - } - // Only closures that actually redeemed remaining shares carry a transaction to post GL - // entries for — an account already at zero shares has none, and posting a zero-value - // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL. - final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname); - if (transactionChange instanceof ShareAccountTransaction) { - ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange); - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - changes.clear(); - changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) { - throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue", - "Unknown data integrity issue with resource."); - } -} +/** + * 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.service; + +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; +import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; +import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.account.service.AccountNumberGenerator; +import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; +import org.apache.fineract.portfolio.note.domain.Note; +import org.apache.fineract.portfolio.note.domain.NoteRepository; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction; +import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; +import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; +import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; + +@RequiredArgsConstructor +public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService { + + private final ShareAccountDataSerializer accountDataSerializer; + + private final ShareAccountRepositoryWrapper shareAccountRepository; + + private final ShareProductRepositoryWrapper shareProductRepository; + + private final AccountNumberGenerator accountNumberGenerator; + + private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; + + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + + private final NoteRepository noteRepository; + + + private final BusinessEventNotifierService businessEventNotifierService; + + @Override + public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) { + try { + ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand); + this.shareAccountRepository.saveAndFlush(account); + generateAccountNumber(account); + // GL entries are intentionally NOT posted at submission time. + // Cashier collects cash and enters GL directly in the transaction entry + // workstation: DR Cash → CR Share Sundry GL (share amount) and + // DR Cash → CR Charges GL (charge amount). The approval step posts the + // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. + + businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account)); + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(account.getId()) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException dve) { + Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); + handleDataIntegrityIssues(jsonCommand, throwable, dve); + return CommandProcessingResult.empty(); + } + } + + private void generateAccountNumber(final ShareAccount account) { + if (account.isAccountNumberRequiresAutoGeneration()) { + final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES); + account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat)); + this.shareAccountRepository.save(account); + } + } + + private Map populateJournalEntries(final ShareAccount account, final Set transactions) { + final Map accountingBridgeData = new HashMap<>(); + Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2; + accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting); + accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE); + accountingBridgeData.put("shareAccountId", account.getId()); + accountingBridgeData.put("shareProductId", account.getShareProduct().getId()); + accountingBridgeData.put("officeId", account.getOfficeId()); + accountingBridgeData.put("currencyCode", account.getCurrency().getCode()); + final List> newTransactionsMap = new ArrayList<>(); + accountingBridgeData.put("newTransactions", newTransactionsMap); + + for (ShareAccountTransaction transaction : transactions) { + final Map transactionDto = new HashMap<>(); + transactionDto.put("officeId", account.getOfficeId()); + transactionDto.put("id", transaction.getId()); + transactionDto.put("date", transaction.getPurchasedDate()); + final Integer status = transaction.getTransactionStatus(); + final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null); + final Integer type = transaction.getTransactionType(); + final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null); + transactionDto.put("status", statusEnum); + transactionDto.put("type", typeEnum); + if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) { + BigDecimal amount = transaction.amount(); + if (transaction.chargeAmount() != null) { + amount = amount.add(transaction.chargeAmount()); + } + transactionDto.put("amount", amount); + } else { + transactionDto.put("amount", transaction.amount()); + } + + transactionDto.put("chargeAmount", transaction.chargeAmount()); + transactionDto.put("paymentTypeId", null); // FIXME::make it cash + // payment + if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) { + final List> chargesPaidData = new ArrayList<>(); + transactionDto.put("chargesPaid", chargesPaidData); + Set chargesPaidBySet = transaction.getChargesPaidBy(); + for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) { + Map chargesPaidDto = new HashMap<>(); + chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId()); + chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId()); + chargesPaidDto.put("amount", chargesPaidBy.getAmount()); + chargesPaidData.add(chargesPaidDto); + } + } + newTransactionsMap.add(transactionDto); + } + return accountingBridgeData; + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + } + // since we are reverting all journal entries we need to add journal + // entries for application request + if (changes.containsKey("reversalIds")) { + ArrayList reversalIds = (ArrayList) changes.get("reversalIds"); + this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate); + journalEntryWritePlatformService.createJournalEntriesForShares( + populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); + changes.remove("reversalIds"); + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException dve) { + Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); + handleDataIntegrityIssues(jsonCommand, throwable, dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account); + ShareAccountTransaction transaction = null; + if (!changes.isEmpty()) { + this.shareAccountRepository.saveAndFlush(account); + transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname); + transaction = account.getShareAccountTransaction(transaction); + if (transaction != null) { + changes.clear(); + changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId()); + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + } + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + Set transactions = account.getShareAccountTransactions(); + Set journalTransactions = new HashSet<>(); + Long totalSubsribedShares = Long.valueOf(0); + + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && transaction.isPurchasTransaction()) { + journalTransactions.add(transaction); + totalSubsribedShares += transaction.getTotalShares(); + } + } + ShareProduct shareProduct = account.getShareProduct(); + shareProduct.addSubscribedShares(totalSubsribedShares); + this.shareProductRepository.save(shareProduct); + + // No journal entries here. Approving an application decides the member may hold the + // shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share + // Capital at this point issues share capital against a Sundry balance that holds no + // receipt for it — in effect funding one member's shares out of money other members + // paid in. The entries belong to settlement, where the money actually arrives: either + // applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting + // their savings (ShareSavingsDebitRequestApiResource). + businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + Set transactions = account.getShareAccountTransactions(); + Set journalTransactions = new HashSet<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && !transaction.isChargeTransaction()) { + journalTransactions.add(transaction); + } + } + + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + + Set transactions = account.getShareAccountTransactions(); + ArrayList journalEntryTransactions = new ArrayList<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && !transaction.isChargeTransaction()) { + journalEntryTransactions.add(transaction.getId()); + } + } + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate); + journalEntryWritePlatformService.createJournalEntriesForShares( + populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + } + + // Charges are deliberately NOT marked paid here. Attaching an entry fee when the + // account is created records what the member owes; activating the account decides they + // may hold shares. Neither collects anything, and nobody has yet said where the money + // is coming from — a cash receipt at the counter, or a debit from the member's savings. + // + // Marking it paid at activation left the fee showing as settled while it was still + // outstanding, so the settlement step then asked the member for the share value alone + // and the fee was never collected at all. It is marked paid where the money actually + // arrives: ShareAdvanceCollectionApiResource (cash receipt) or + // ShareSavingsDebitRequestApiResource (savings debit), both of which set + // amount_paid_derived and amount_outstanding_derived alongside the flag. + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); + Long totalSubscribedShares = Long.valueOf(0); + if (transactionIds != null) { + Set transactions = new HashSet<>(); + for (Long id : transactionIds) { + ShareAccountTransaction transaction = account.retrievePurchasedShares(id); + transactions.add(transaction); + totalSubscribedShares += transaction.getTotalShares(); + } + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + if (!totalSubscribedShares.equals(Long.valueOf(0))) { + ShareProduct shareProduct = account.getShareProduct(); + shareProduct.addSubscribedShares(totalSubscribedShares); + this.shareProductRepository.save(shareProduct); + } + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); + if (transactionIds != null) { + Set transactions = new HashSet<>(); + for (Long id : transactionIds) { + ShareAccountTransaction transaction = account.retrievePurchasedShares(id); + transactions.add(transaction); + } + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.saveAndFlush(account); + ShareAccountTransaction transaction = (ShareAccountTransaction) changes + .get(ShareAccountApiConstants.requestedshares_paramname); + // after saving, entity will have different object. So need to + // retrieve the entity object + transaction = account.getShareAccountTransaction(transaction); + Long redeemShares = transaction.getTotalShares(); + ShareProduct shareProduct = account.getShareProduct(); + // remove the redeem shares from total subscribed shares + shareProduct.removeSubscribedShares(redeemShares); + this.shareProductRepository.saveAndFlush(shareProduct); + + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + changes.clear(); + changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); + + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account); + // Account status/closedDate are applied regardless of whether there was anything left to + // redeem — save and note-capture must not be gated on a transaction actually existing. + this.shareAccountRepository.saveAndFlush(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + this.noteRepository.save(note); + } + // Only closures that actually redeemed remaining shares carry a transaction to post GL + // entries for — an account already at zero shares has none, and posting a zero-value + // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL. + final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname); + if (transactionChange instanceof ShareAccountTransaction) { + ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange); + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + changes.clear(); + changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) { + throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue", + "Unknown data integrity issue with resource."); + } +} 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 eed5904cc..3d5aa3f2f 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 @@ -125,11 +125,10 @@ public class ShareAccountsConfiguration { ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository, AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository, - org.springframework.jdbc.core.JdbcTemplate jdbcTemplate, BusinessEventNotifierService businessEventNotifierService) { return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository, accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository, - jdbcTemplate, businessEventNotifierService); + businessEventNotifierService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java new file mode 100644 index 000000000..6f84b86ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -0,0 +1,579 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Declares one dividend across every share product at once. + * + * A society's board resolves a single rate for a financial year — it does not resolve one for each share product — but + * Fineract only declares dividends one product at a time. Doing that by hand means repeating the same decision per + * product and hoping the rate and period match, when a mismatch would quietly pay two classes of member at different + * rates out of one pool. + * + * Each product still gets its own declaration row, because share capital lives in a separate GL per product + * (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one + * rate, one period, applied to every product that has members holding shares in it. + * + * The declarations are created through the existing per-product command, so the whole downstream chain — statutory + * appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it + * does for a single-product declaration. + */ +@Path("/v1/shareproducts/dividends") +@Component +@Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.") +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class AllProductsDividendApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; + private static final int DIVIDEND_STATUS_INITIATED = 100; + + /** The account a dividend is declared against, once year-end closing has landed the profit in it. */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; + + /** Every member receives the declared rate on their own capital — what an AGM resolution promises. */ + private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL"; + /** The pool is a share of distributable profit, split across members afterwards. */ + private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── GET /v1/shareproducts/dividends ── Every declaration, across products ── + + @GET + public Response list() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, d.product_id, p.name AS product_name, d.amount, + d.dividend_period_start_date, d.dividend_period_end_date, d.status, d.created_date, + (SELECT COUNT(*) FROM m_share_account_dividend_details x + WHERE x.dividend_pay_out_id = d.id AND x.amount > 0) AS member_count + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + ORDER BY d.dividend_period_end_date DESC, d.product_id + """); + + final List> entries = new ArrayList<>(); + for (final Map row : rows) { + final Map entry = new LinkedHashMap<>(); + entry.put("dividendId", row.get("id")); + entry.put("productId", row.get("product_id")); + entry.put("productName", row.get("product_name")); + entry.put("amount", row.get("amount")); + entry.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + entry.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date"))); + entry.put("financialYear", financialYearOf(String.valueOf(row.get("dividend_period_end_date")))); + entry.put("approved", ((Number) row.get("status")).intValue() != DIVIDEND_STATUS_INITIATED); + entry.put("memberCount", row.get("member_count")); + entry.put("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date"))); + entries.add(entry); + } + return Response.ok(gson.toJson(entries)).build(); + } + + // ── GET /v1/shareproducts/dividends/preview ── What a rate costs, per product ── + + /** + * What declaring this rate would cost, product by product, before anything is created. + * + * A rate is a promise about each member's own paid-up capital, so the amount it costs depends on how much capital + * each product's members held and for how long — which differs per product. Showing that split before the officer + * commits is what makes a single cross-product decision safe to take. + * + * A product with no eligible members is reported with a zero and a reason rather than omitted, so it is clear it + * was considered and skipped rather than overlooked. + */ + @GET + @Path("/preview") + public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("ratePercentage") final BigDecimal ratePercentage, + @QueryParam("dividendBasis") final String dividendBasis) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final LocalDate from = parseDate(fromDate); + final LocalDate to = parseDate(toDate); + if (to.isBefore(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + final String basis = normaliseBasis(dividendBasis); + + final List capitals = capitalPerProduct(from, to); + final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); + + final ProfitWaterfall waterfall = profitWaterfall(from, to); + + // Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in + // proportion to the capital each carries — a share of profit is a society-wide figure, so it + // cannot be computed per product the way a rate on capital can. + final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null + ? percentageOf(waterfall.distributableProfit, ratePercentage) + : null; + + final List> products = new ArrayList<>(); + BigDecimal grandTotal = BigDecimal.ZERO; + + for (final ProductCapital capital : capitals) { + final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital, + profitPool); + + final Map entry = new LinkedHashMap<>(); + entry.put("productId", capital.productId); + entry.put("productName", capital.productName); + entry.put("weightedCapital", capital.weightedCapital); + entry.put("amount", amount); + entry.put("eligible", capital.weightedCapital.signum() > 0); + entry.put("reason", capital.weightedCapital.signum() > 0 ? null + : "No member has held shares in this product long enough to qualify for this period."); + entry.put("alreadyDeclared", hasDeclarationFor(capital.productId, from, to)); + products.add(entry); + grandTotal = grandTotal.add(amount); + } + + final Map out = new LinkedHashMap<>(); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("financialYear", financialYearOf(toDate)); + out.put("dividendBasis", basis); + out.put("ratePercentage", ratePercentage); + out.put("totalWeightedCapital", totalWeightedCapital); + out.put("profitGlCode", PROFIT_GL_CODE); + // The whole waterfall, so the screen can show where the declarable figure came from rather + // than presenting it as a number the officer has to take on trust. + out.putAll(waterfall.asMap()); + // What is left in the profit account after this declaration — the amount that will be swept + // to Bank Profit on approval, and which no later year's dividend can draw on. + out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO)); + out.put("exceedsDistributableProfit", grandTotal.compareTo(waterfall.distributableProfit) > 0); + out.put("totalAmount", grandTotal); + out.put("products", products); + return Response.ok(gson.toJson(out)).build(); + } + + // ── POST /v1/shareproducts/dividends ── Declare across every product ── + + /** + * Creates one declaration per eligible product, all for the same period and rate. + * + * Products with no eligible members, and products that already carry a declaration overlapping this period, are + * skipped and reported rather than failing the whole request — one product being ineligible is not a reason to + * leave the rest undeclared. + */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public Response declare(final String body) { + this.context.authenticatedUser(); + + final JsonObject json = parseBody(body); + final String fromDate = getString(json, "dividendPeriodStartDate"); + final String toDate = getString(json, "dividendPeriodEndDate"); + if (fromDate == null || toDate == null) { + throw apiError(400, "dividendPeriodStartDate and dividendPeriodEndDate (yyyy-MM-dd) are both required"); + } + final LocalDate from = parseDate(fromDate); + final LocalDate to = parseDate(toDate); + final String basis = normaliseBasis(getString(json, "dividendBasis")); + + final String rateText = getString(json, "ratePercentage"); + if (rateText == null) { + throw apiError(400, "ratePercentage is required"); + } + final BigDecimal rate = new BigDecimal(rateText); + if (rate.signum() <= 0) { + throw apiError(400, "ratePercentage must be greater than zero"); + } + + final List capitals = capitalPerProduct(from, to); + final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) + ? percentageOf(profitWaterfall(from, to).distributableProfit, rate) + : null; + + final List> created = new ArrayList<>(); + final List> skipped = new ArrayList<>(); + + for (final ProductCapital capital : capitals) { + final Long productId = capital.productId; + final String productName = capital.productName; + + if (hasDeclarationFor(productId, from, to)) { + skipped.add(skipRow(productId, productName, "A dividend is already declared for this product over this period.")); + continue; + } + + if (capital.weightedCapital.signum() <= 0) { + skipped.add(skipRow(productId, productName, + "No member has held shares in this product long enough to qualify for this period.")); + continue; + } + + final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool); + if (amount.signum() <= 0) { + skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product.")); + continue; + } + + // Routed through the existing per-product command so the whole downstream chain — + // appropriations, rounding, residue sweep, ceiling check — runs exactly as it does for a + // single-product declaration, rather than being reimplemented here. + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("dividendPeriodStartDate", fromDate); + payload.addProperty("dividendPeriodEndDate", toDate); + payload.addProperty("dividendAmount", amount); + + try { + final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId) + .withJson(payload.toString()).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); + + final Map row = new LinkedHashMap<>(); + row.put("productId", productId); + row.put("productName", productName); + row.put("amount", amount); + row.put("dividendId", result.getSubResourceId()); + created.add(row); + } catch (final RuntimeException e) { + // Most often the distributable-profit ceiling: an earlier product in this same run has + // already consumed the pool. Reported per product so the officer can see which ones + // went through and why the rest did not, instead of losing all of them to one failure. + skipped.add(skipRow(productId, productName, + e.getMessage() == null ? "Could not declare for this product." : e.getMessage())); + } + } + + if (created.isEmpty()) { + throw apiError(400, "No dividend could be declared. " + (skipped.isEmpty() ? "There are no share products." + : skipped.get(0).get("reason").toString())); + } + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYearOf(toDate)); + out.put("declarationsCreated", created.size()); + out.put("created", created); + out.put("skipped", skipped); + return Response.ok(gson.toJson(out)).build(); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /** One product's weighted paid-up capital for the period. */ + private static final class ProductCapital { + + private final Long productId; + private final String productName; + private final BigDecimal weightedCapital; + + private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) { + this.productId = productId; + this.productName = productName; + this.weightedCapital = weightedCapital; + } + } + + /** Weighted paid-up capital for every share product, in one pass. */ + private List capitalPerProduct(final LocalDate from, final LocalDate to) { + final List capitals = new ArrayList<>(); + for (final Map product : jdbcTemplate + .queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) { + final Long productId = ((Number) product.get("id")).longValue(); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod); + capitals.add(new ProductCapital(productId, (String) product.get("name"), + capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP))); + } + return capitals; + } + + /** + * What one product's declaration comes to, under whichever basis the officer chose. + * + * RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital, + * so each product's amount follows directly from the capital its members hold. + * + * PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure + * at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the + * same across products — any other split would pay two members with identical holdings different amounts purely + * because they hold different share classes. + */ + private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital, + final BigDecimal totalWeightedCapital, final BigDecimal profitPool) { + if (ratePercentage == null || weightedCapital.signum() <= 0) { + return BigDecimal.ZERO; + } + if (BASIS_PERCENT_OF_PROFIT.equals(basis)) { + if (profitPool == null || totalWeightedCapital.signum() <= 0) { + return BigDecimal.ZERO; + } + return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP); + } + return percentageOf(weightedCapital, ratePercentage); + } + + private BigDecimal percentageOf(final BigDecimal base, final BigDecimal percentage) { + return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + private String normaliseBasis(final String basis) { + return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL; + } + + /** + * The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of + * it, and what is left for a dividend. + * + * Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the + * society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a + * large one that has already been heavily appropriated — and the reserve and education percentages are + * configurable, so the amounts they take cannot be inferred from the result either. + */ + private static final class ProfitWaterfall { + + private final BigDecimal netProfit; + private final BigDecimal statutoryReservePercentage; + private final BigDecimal statutoryReserveAmount; + private final BigDecimal educationFundPercentage; + private final BigDecimal educationFundAmount; + private final BigDecimal distributableProfit; + + private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage, + final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage, + final BigDecimal educationFundAmount, final BigDecimal distributableProfit) { + this.netProfit = netProfit; + this.statutoryReservePercentage = statutoryReservePercentage; + this.statutoryReserveAmount = statutoryReserveAmount; + this.educationFundPercentage = educationFundPercentage; + this.educationFundAmount = educationFundAmount; + this.distributableProfit = distributableProfit; + } + + private Map asMap() { + final Map out = new LinkedHashMap<>(); + out.put("netProfit", netProfit); + out.put("statutoryReservePercentage", statutoryReservePercentage); + out.put("statutoryReserveAmount", statutoryReserveAmount); + out.put("educationFundPercentage", educationFundPercentage); + out.put("educationFundAmount", educationFundAmount); + out.put("distributableProfit", distributableProfit); + return out; + } + } + + /** + * The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education + * fund, which are appropriated before any dividend. + * + * Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never + * disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than + * guessed at from a P&L that is still moving. + */ + private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) { + final BigDecimal netProfit = netProfitForPeriod(from, to); + + final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); + final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); + + // Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and + // nothing to distribute, so the whole waterfall collapses to zero rather than producing a + // negative "reserve" that would read as money owed back to the society. + if (netProfit == null || netProfit.signum() <= 0) { + final BigDecimal zero = BigDecimal.ZERO.setScale(2); + return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero, + zero); + } + + final BigDecimal reserve = percentageOf(netProfit, reservePercentage); + final BigDecimal educationFund = percentageOf(netProfit, educationPercentage); + return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund, + netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO)); + } + + /** + * The period's net profit, read exactly the way the declaration validator reads it. + * + * This deliberately mirrors {@code ShareProductWritePlatformServiceJpaRepositoryImpl.profitForPeriod} rather than + * reading the profit GL directly. A closed year's profit lives in the profit GL, but a year is only "closed" once + * it is registered in {@code m_income_profit_transfer} — and after closing, income-less-expense for the period is + * zero, because closing is what zeroed those GLs. Reading the GL without that check would show a healthy profit on + * the preview and then have the declaration rejected for having none, which is exactly the contradiction an + * officer cannot act on. + */ + private BigDecimal netProfitForPeriod(final LocalDate from, final LocalDate to) { + final int fyStartYear = from.getMonthValue() >= 4 ? from.getYear() : from.getYear() - 1; + final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100); + final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31); + + if (!from.isAfter(fyStart) && !to.isBefore(fyEnd)) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount != null && closedCount > 0) { + final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + return closedProfit == null ? BigDecimal.ZERO : closedProfit; + } + } + + // The year is still open, so the profit is whatever the P&L says right now. + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to)); + return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense")); + } + + /** A configured appropriation rate, or zero when it has not been set. */ + private BigDecimal configuredPercentage(final String configurationName) { + final List values = jdbcTemplate.queryForList( + "SELECT value FROM c_configuration WHERE name = ? AND enabled = true", BigDecimal.class, configurationName); + return values.isEmpty() || values.get(0) == null ? BigDecimal.ZERO : values.get(0); + } + + /** + * Paid-up capital x days held, across a product's eligible accounts. + * + * Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a + * holding already in force at the period start is treated as starting there, and a purchase that has not cleared + * the product's minimum active period is skipped. + */ + private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, + final int minimumActivePeriod) { + final BigDecimal total = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(t.amount * (? - GREATEST(t.transaction_date, ?))), 0) + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? AND sa.status_enum = 300 + AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND t.transaction_date <= ? + AND (? - GREATEST(t.transaction_date, ?)) >= ? + """, BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to), + java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod); + return total == null ? BigDecimal.ZERO : total; + } + + private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) { + final Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_share_product_dividend_pay_out + WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, Integer.class, productId, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from)); + return count != null && count > 0; + } + + private Map skipRow(final Long productId, final String productName, final String reason) { + final Map row = new LinkedHashMap<>(); + row.put("productId", productId); + row.put("productName", productName); + row.put("reason", reason); + return row; + } + + /** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */ + private String financialYearOf(final String isoDate) { + final LocalDate date = LocalDate.parse(isoDate); + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + private LocalDate parseDate(final String isoDate) { + try { + return LocalDate.parse(isoDate); + } catch (final RuntimeException e) { + throw apiError(400, "Dates must be in yyyy-MM-dd format. Received: " + isoDate); + } + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw apiError(400, "Request body is required."); + } + try { + return com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + } catch (final RuntimeException e) { + throw apiError(400, "Request body is not valid JSON."); + } + } + + private String getString(final JsonObject json, final String field) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return null; + } + final String value = json.get(field).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject errorBody = new JsonObject(); + errorBody.addProperty("message", message); + errorBody.addProperty("defaultUserMessage", message); + errorBody.addProperty("httpStatus", httpStatus); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java index 9e04f493c..c4a63aa8c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java @@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.security.service.SqlValidator; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountDividendData; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountDividendReadPlatformService; import org.apache.fineract.portfolio.shareproducts.data.ShareProductDividendPayOutData; +import org.apache.fineract.portfolio.shareproducts.service.ShareDividendRoundingResidueReadService; import org.apache.fineract.portfolio.shareproducts.service.ShareProductDividendReadPlatformService; import org.springframework.stereotype.Component; @@ -61,6 +62,7 @@ public class ShareDividendApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final ShareAccountDividendReadPlatformService shareAccountDividendReadPlatformService; private final ShareProductDividendReadPlatformService shareProductDividendReadPlatformService; + private final ShareDividendRoundingResidueReadService shareDividendRoundingResidueReadService; private final SqlValidator sqlValidator; @GET @@ -100,6 +102,37 @@ public class ShareDividendApiResource { return this.toApiAccountDetailJsonSerializer.serialize(dividendDetails); } + /** + * What rounding left behind on one declaration. + * + * Members' entitlements are rounded down to whole rupees so each can be paid out as a Demand Draft, which leaves a + * few paise undistributed on every declaration. This reports that gap for the declaration being viewed — declared + * versus allocated versus residue — so the difference between the board's figure and the sum of the member rows on + * screen is explained rather than left as an apparent discrepancy. + */ + @GET + @Path("{dividendId}/rounding-residue") + @Produces({ MediaType.APPLICATION_JSON }) + public String retrieveRoundingResidue(@PathParam("productId") final Long productId, + @PathParam("dividendId") final Long dividendId) { + this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveOne(dividendId)); + } + + /** + * Rounding residue across every declaration of this product, newest period first. + * + * Kept per declaration and never deleted, so an auditor can be shown years afterwards exactly where each + * declaration's undistributed paise went and which journal entry moved them. + */ + @GET + @Path("rounding-residue") + @Produces({ MediaType.APPLICATION_JSON }) + public String retrieveRoundingResidueHistory(@PathParam("productId") final Long productId) { + this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveAllForProduct(productId)); + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java new file mode 100644 index 000000000..aa99cbaf3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java @@ -0,0 +1,262 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * What a given dividend RATE actually costs for one share product over one period. + * + * "9% dividend" in an AGM resolution is a promise about each member's own paid-up capital: a member holding 10,000 of + * share capital for the full year receives 900. That is not the same thing as distributing 9% of the society's profit — + * under a profit-share the amount an individual member receives depends on how many OTHER members hold shares, so the + * same declared "9%" pays a different rate every year. This endpoint sizes the pool the promised way, so the amount + * declared is whatever it costs to actually honour the rate. + * + * The weighting matches {@code ShareProductDividendAssembler} exactly — same eligible-account rule, same + * minimum-active-period skip, same share-days accumulation — because that assembler is what splits the pool across + * members once the declaration is created. If the two disagreed, the rate quoted here would not be the rate members + * were paid. + */ +@Path("/v1/shareproduct/{productId}/dividend-capital-preview") +@Component +@Tag(name = "Share Dividend Capital Preview", description = "Weighted paid-up share capital for a period, and the pool required to pay a given rate per annum on it.") +@RequiredArgsConstructor +public class ShareDividendCapitalPreviewApiResource { + + /** Denominator for an annual rate; matches the assembler's exact-day arithmetic. */ + private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365); + + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("productId") final Long productId, @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, @QueryParam("ratePercentage") final BigDecimal ratePercentage) { + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final Date from; + final Date to; + try { + from = Date.valueOf(fromDate); + to = Date.valueOf(toDate); + } catch (final IllegalArgumentException e) { + throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format"); + } + if (to.before(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + + final Map product = productOrThrow(productId); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + + final List> weighted = weightedCapitalPerAccount(productId, from, to, minimumActivePeriod, + allowInactiveClients); + + BigDecimal totalCapitalDays = BigDecimal.ZERO; + long eligibleAccounts = 0; + for (final Map account : weighted) { + final BigDecimal capitalDays = (BigDecimal) account.get("capitalDays"); + totalCapitalDays = totalCapitalDays.add(capitalDays); + if (capitalDays.signum() > 0) { + eligibleAccounts++; + } + } + + // Capital-days / 365 is the average capital that was actually at risk across the period — + // the base an annual rate applies to. A member who held for half the year contributes half + // their capital to it, which is exactly the pro-rata entitlement they are owed. + final BigDecimal weightedCapital = totalCapitalDays.divide(DAYS_IN_YEAR, 6, RoundingMode.HALF_UP); + + final Map response = new LinkedHashMap<>(); + response.put("productId", productId); + response.put("productName", product.get("name")); + response.put("fromDate", fromDate); + response.put("toDate", toDate); + response.put("minimumActivePeriodDays", minimumActivePeriod); + response.put("eligibleAccounts", eligibleAccounts); + response.put("totalCapitalDays", totalCapitalDays); + response.put("weightedCapital", weightedCapital); + if (ratePercentage != null) { + response.put("ratePercentage", ratePercentage); + response.put("requiredAmount", + weightedCapital.multiply(ratePercentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP)); + } else { + response.put("ratePercentage", null); + response.put("requiredAmount", null); + } + return gson.toJson(response); + } + + private Map productOrThrow(final Long productId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT name, minimum_active_period_frequency, allow_dividends_inactive_clients + FROM m_share_product WHERE id = ? + """, productId); + if (rows.isEmpty()) { + throw apiError(404, "Share product " + productId + " does not exist"); + } + return rows.get(0); + } + + /** + * Paid-up capital x days held, per eligible account. + * + * Mirrors {@code ShareProductDividendAssembler.calculateNumberOfShareDays}: only APPROVED transactions count, + * charge payments are excluded, a purchase already in force at the period start is treated as starting at the + * period start, and a purchase that has not yet cleared the minimum active period is skipped entirely. Redemptions + * reduce the running holding and the holding is floored at zero, so a redemption of shares whose purchase was + * skipped cannot drive an account negative — the same defensive floor the assembler carries. + * + * Unlike the assembler this weights by AMOUNT rather than share count, since a rate is quoted on paid-up capital + * and unit price can differ between purchases. + */ + private List> weightedCapitalPerAccount(final Long productId, final Date from, final Date to, + final int minimumActivePeriod, final boolean allowInactiveClients) { + // 300 = ACTIVE, 600 = CLOSED (ShareAccountStatusType); 300 = APPROVED transaction status, + // 500 = PURCHASED, 700 = CHARGE_PAYMENT (PurchasedSharesStatusType). + final String accountStatusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final String sql = """ + SELECT sa.id AS account_id, t.transaction_date, t.total_shares, t.unit_price, t.amount, t.type_enum + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? + """ + accountStatusClause + """ + AND t.status_enum = 300 + AND t.type_enum <> 700 + AND t.transaction_date <= ? + ORDER BY sa.id, t.transaction_date, t.id + """; + + final List> rows = allowInactiveClients ? jdbcTemplate.queryForList(sql, productId, from, to) + : jdbcTemplate.queryForList(sql, productId, to); + + final Map capitalDaysPerAccount = new LinkedHashMap<>(); + Long currentAccountId = null; + BigDecimal runningCapital = BigDecimal.ZERO; + java.time.LocalDate lastAppliedDate = null; + BigDecimal accumulated = BigDecimal.ZERO; + + final java.time.LocalDate periodStart = from.toLocalDate(); + final java.time.LocalDate periodEnd = to.toLocalDate(); + + for (final Map row : rows) { + final Long accountId = ((Number) row.get("account_id")).longValue(); + if (!accountId.equals(currentAccountId)) { + if (currentAccountId != null) { + capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital)); + } + currentAccountId = accountId; + runningCapital = BigDecimal.ZERO; + lastAppliedDate = null; + accumulated = BigDecimal.ZERO; + } + + java.time.LocalDate transactionDate = ((Date) row.get("transaction_date")).toLocalDate(); + if (transactionDate.isBefore(periodStart)) { + transactionDate = periodStart; + } + + final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500; + if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) { + continue; + } + + if (lastAppliedDate != null) { + final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, transactionDate); + accumulated = accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days))); + } + lastAppliedDate = transactionDate; + + final BigDecimal transactionCapital = capitalOf(row); + runningCapital = isPurchase ? runningCapital.add(transactionCapital) : runningCapital.subtract(transactionCapital); + if (runningCapital.signum() < 0) { + runningCapital = BigDecimal.ZERO; + } + } + if (currentAccountId != null) { + capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital)); + } + + return capitalDaysPerAccount.entrySet().stream().map(entry -> { + final Map account = new LinkedHashMap<>(); + account.put("accountId", entry.getKey()); + account.put("capitalDays", entry.getValue()); + return account; + }).toList(); + } + + /** Carries the final holding forward to the period end, the way the assembler's tail does. */ + private BigDecimal closeAccount(final BigDecimal accumulated, final java.time.LocalDate lastAppliedDate, + final java.time.LocalDate periodEnd, final BigDecimal runningCapital) { + if (lastAppliedDate == null) { + return accumulated; + } + final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, periodEnd); + return accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days))); + } + + /** + * Paid-up capital the transaction moved. `amount` is authoritative where present; shares x unit price is the + * fallback for rows that predate amount being populated. + */ + private BigDecimal capitalOf(final Map row) { + final Object amount = row.get("amount"); + if (amount != null) { + return (BigDecimal) amount; + } + final Object shares = row.get("total_shares"); + final Object unitPrice = row.get("unit_price"); + if (shares == null || unitPrice == null) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(((Number) shares).longValue()).multiply((BigDecimal) unitPrice); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java index 90d9ac778..4b49ae857 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java @@ -82,4 +82,22 @@ public class ShareProductDividendPayOutDetails extends AbstractAuditableCustom { return ShareProductDividendStatusType.fromInt(this.status); } + /** + * Entitlements dropped for falling under the minimum payable amount, before they were zeroed. + * + * Not persisted — it exists only to carry the figure from the assembler, which is the only place that knows what a + * dropped member would have received, to the residue record written straight afterwards. Reading it back from + * m_share_account_dividend_details is impossible: those rows hold the zeroed amount, not the original. + */ + @jakarta.persistence.Transient + private BigDecimal belowMinimumAmount = BigDecimal.ZERO; + + public BigDecimal getBelowMinimumAmount() { + return this.belowMinimumAmount == null ? BigDecimal.ZERO : this.belowMinimumAmount; + } + + public void addBelowMinimumAmount(final BigDecimal amount) { + this.belowMinimumAmount = getBelowMinimumAmount().add(amount); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java new file mode 100644 index 000000000..47e1dae60 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Reports what a declaration did NOT pay out to members, and why. + * + * Two things hold an amount back. Every entitlement is rounded down to a whole rupee, because a dividend is paid as a + * Demand Draft and a branch cannot draw one for 421.32. And an entitlement below the minimum payable amount is dropped + * entirely, because drawing a draft for a few rupees costs the society more than the draft is worth. + * + * Both go to Bank Profit at declaration time — rounding down and dropping below the floor mean no member was ever + * promised that money, so unlike an unclaimed dividend there is nobody who could claim it and no three-year wait. The + * record is what persists: it is what answers an auditor asking why a declaration of 1,538.91 allocated 1,538 to + * members, per declaration, with the journal entry that moved the difference. + */ +@Service +@RequiredArgsConstructor +public class ShareDividendRoundingResidueReadService { + + private final JdbcTemplate jdbcTemplate; + + private static final String COLUMNS = """ + r.id, r.dividend_pay_out_id, r.product_id, p.name AS product_name, + r.declared_amount, r.allocated_amount, r.residue_amount, r.member_count, + r.below_minimum_count, r.below_minimum_amount, + r.currency_code, r.period_start_date, r.period_end_date, + r.swept_to_profit_date, r.gl_transaction_id + """; + + /** The residue for one declaration, or null if none was recorded (declarations made before this was tracked). */ + public Map retrieveOne(final Long dividendPayOutId) { + final List> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """ + FROM m_share_dividend_rounding_residue r + JOIN m_share_product p ON p.id = r.product_id + WHERE r.dividend_pay_out_id = ? + """, dividendPayOutId); + return rows.isEmpty() ? null : toResponse(rows.get(0)); + } + + /** Every declaration's residue for one product, newest period first, with a running total. */ + public Map retrieveAllForProduct(final Long productId) { + final List> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """ + FROM m_share_dividend_rounding_residue r + JOIN m_share_product p ON p.id = r.product_id + WHERE r.product_id = ? + ORDER BY r.period_end_date DESC, r.id DESC + """, productId); + + final List> entries = new ArrayList<>(); + BigDecimal totalResidue = BigDecimal.ZERO; + for (final Map row : rows) { + entries.add(toResponse(row)); + totalResidue = totalResidue.add((BigDecimal) row.get("residue_amount")); + } + + final Map response = new LinkedHashMap<>(); + response.put("productId", productId); + response.put("declarationCount", entries.size()); + response.put("totalResidueSweptToProfit", totalResidue); + response.put("entries", entries); + return response; + } + + private Map toResponse(final Map row) { + final Map entry = new LinkedHashMap<>(); + entry.put("id", row.get("id")); + entry.put("declarationId", row.get("dividend_pay_out_id")); + entry.put("productId", row.get("product_id")); + entry.put("productName", row.get("product_name")); + entry.put("declaredAmount", row.get("declared_amount")); + entry.put("allocatedAmount", row.get("allocated_amount")); + entry.put("residueAmount", row.get("residue_amount")); + entry.put("memberCount", row.get("member_count")); + // How much of the residue came from entitlements too small to draw a draft for, as opposed + // to paise shaved off larger ones — the two are worth telling apart when reviewing whether + // the minimum is set sensibly. + entry.put("belowMinimumCount", row.get("below_minimum_count")); + entry.put("belowMinimumAmount", row.get("below_minimum_amount")); + entry.put("currencyCode", row.get("currency_code")); + entry.put("periodStartDate", String.valueOf(row.get("period_start_date"))); + entry.put("periodEndDate", String.valueOf(row.get("period_end_date"))); + final Object sweptDate = row.get("swept_to_profit_date"); + entry.put("sweptToProfitDate", sweptDate == null ? null : String.valueOf(sweptDate)); + entry.put("glTransactionId", row.get("gl_transaction_id")); + return entry; + } +} 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 3755e227f..b742bbe0a 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 @@ -19,9 +19,11 @@ package org.apache.fineract.portfolio.shareproducts.service; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -36,12 +38,20 @@ import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatf import org.apache.fineract.portfolio.shareproducts.data.ShareProductData; import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails; import org.apache.fineract.portfolio.shareproducts.exception.ShareAccountsNotFoundException; +import org.springframework.jdbc.core.JdbcTemplate; @RequiredArgsConstructor public class ShareProductDividendAssembler { + /** Smallest entitlement worth paying out; seeded by changelog 0480. */ + private static final String CONFIG_MINIMUM_PAYABLE_AMOUNT = "dividend-minimum-payable-amount"; + + /** Entitlements are rounded down to a multiple of this; seeded by changelog 0480. */ + private static final String CONFIG_ROUNDING_MULTIPLE = "dividend-rounding-multiple"; + private final ShareProductReadPlatformService shareProductReadPlatformService; private final ShareAccountReadPlatformService shareAccountReadPlatformService; + private final JdbcTemplate jdbcTemplate; public ShareProductDividendPayOutDetails calculateDividends(final Long productId, final BigDecimal amount, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { @@ -67,12 +77,28 @@ public class ShareProductDividendAssembler { if (numberOfShareDays > 0) { double amountPerShareDay = amount.doubleValue() / numberOfShareDays; + final BigDecimal minimumPayableAmount = configuredAmount(CONFIG_MINIMUM_PAYABLE_AMOUNT); + final BigDecimal roundingMultiple = configuredAmount(CONFIG_ROUNDING_MULTIPLE); productDividendPayOutDetails = new ShareProductDividendPayOutDetails(productId, Money.of(currency, amount).getAmount(), dividendPeriodStartDate, dividendPeriodEndDate); for (ShareAccountData accountData : shareAccountDatas) { long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId()); double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay; - final Money accountAmount = Money.of(currency, BigDecimal.valueOf(amountForAccount)); + // Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87 + // becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts + // cash in notes and coins, so round figures are what it can actually hand over. + // Rounding down rather than to nearest also guarantees the society never commits more + // than it declared: allocations always sum to at or below the declared amount. + BigDecimal roundedAmount = roundDownToMultiple(BigDecimal.valueOf(amountForAccount), roundingMultiple); + // An entitlement below the minimum is dropped to zero rather than paid: a draft costs + // the society a leaflet, stationery and counter time, so drawing one for a few rupees + // hands over less than it costs. What is dropped is swept to profit with the rounding + // residue and reported per declaration, so it is never silently lost. + if (roundedAmount.compareTo(minimumPayableAmount) < 0) { + productDividendPayOutDetails.addBelowMinimumAmount(roundedAmount); + roundedAmount = BigDecimal.ZERO; + } + final Money accountAmount = Money.of(currency, roundedAmount); ShareAccountDividendDetails dividendDetails = new ShareAccountDividendDetails(accountData.getId(), accountAmount.getAmount(), productDividendPayOutDetails); productDividendPayOutDetails.getAccountDividendDetails().add(dividendDetails); @@ -82,6 +108,33 @@ public class ShareProductDividendAssembler { return productDividendPayOutDetails; } + /** + * Rounds an entitlement down to the nearest multiple: at a multiple of 5, 546.789 becomes 545 and 121.87 becomes + * 120. + * + * A multiple of zero or one (or a disabled configuration row) means no multiple applies, and the amount is simply + * floored to a whole rupee — paise still cannot be paid out. + */ + private BigDecimal roundDownToMultiple(final BigDecimal amount, final BigDecimal multiple) { + if (multiple.compareTo(BigDecimal.ONE) <= 0) { + return amount.setScale(0, RoundingMode.FLOOR); + } + return amount.divide(multiple, 0, RoundingMode.FLOOR).multiply(multiple); + } + + /** + * An amount from configuration. A missing or disabled row means the rule does not apply to this society, which is + * zero — not a failure. + */ + private BigDecimal configuredAmount(final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + private long calculateNumberOfShareDays(final LocalDate postingDate, final LocalDate lastDividendPostDate, final int minimumActivePeriod, final Collection shareAccountDatas, final Map numberOfSharesdaysPerAccount) { 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 9f831565c..62624f7d1 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 @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.shareproducts.service; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import jakarta.persistence.PersistenceException; import java.math.BigDecimal; import java.time.LocalDate; @@ -43,7 +44,14 @@ import org.apache.fineract.infrastructure.event.business.domain.share.ShareProdu import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository; import org.apache.fineract.portfolio.shareproducts.constants.ShareProductApiConstants; import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails; @@ -54,14 +62,6 @@ import org.apache.fineract.portfolio.shareproducts.exception.ShareProductEditPen import org.apache.fineract.portfolio.shareproducts.exception.ShareProductNoPendingEditException; import org.apache.fineract.portfolio.shareproducts.exception.ShareProductSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.shareproducts.serialization.ShareProductDataSerializer; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper; -import com.google.gson.JsonObject; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.JdbcTemplate; @@ -90,8 +90,40 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP // FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole // accounting module into this constructor just for two int constants. - private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; + + /** + * The year's closed-out profit a dividend is declared out of; kept in step with + * IncomeProfitTransferService.PROFIT_GL_CODE. + */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + /** + * Where profit settles once a declaration has been approved — the undistributed remainder and the rounding + * residue. Nothing here ever enters a later dividend calculation. + */ + private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01"; + + // Statutory appropriation funds, seeded by changelog 0478. Resolved by GL code rather than a + // financial activity because they are society-specific funds with no native Fineract activity. + private static final String STATUTORY_RESERVE_GL_CODE = "STATRES01"; + private static final String EDUCATION_FUND_GL_CODE = "EDUFUND01"; + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + + private static final String JOURNAL_ENTRY_INSERT_SQL = """ + INSERT INTO acc_gl_journal_entry + (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, + description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc, + submitted_on_date, transaction_date) + VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) + """; + + private static final String CONFIG_STATUTORY_RESERVE_PERCENTAGE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND_PERCENTAGE = "dividend-education-fund-percentage"; @Override public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) { @@ -160,6 +192,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element); validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate); + validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount); + final String financialYear = financialYearOf(dividendPeriodEndDate); final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId, dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate); @@ -169,7 +203,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails); final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId); - postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount); + postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate, + dividendPeriodEndDate, financialYear); + postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount, + financialYear); + recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount, + dividendPayOutDetails.getBelowMinimumAmount(), dividendPeriodStartDate, dividendPeriodEndDate); businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId)); @@ -186,10 +225,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP /** * A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see - * {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is - * a real reservation of that period's profit. Two declarations covering the same days would - * pay members twice for the same period, so any overlap against an existing declaration for - * this product is blocked outright. + * {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is a real reservation of + * that period's profit. Two declarations covering the same days would pay members twice for the same period, so any + * overlap against an existing declaration for this product is blocked outright. */ private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { @@ -206,15 +244,443 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own - * Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just - * silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry - * pair rather than through the transaction-bound AccountingProcessorHelper (which expects a - * loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if - * the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity - * Mappings yet, so dividend declaration itself is never blocked by missing GL setup. + * Moves the profit this declaration did not distribute out of the distributable pool, once the declaration is + * approved. + * + *
+     *   Dr  Bank Profit (Non-Distributable)     what was left after the dividend
+     *       Cr  Bank Profit
+     * 
+ * + * Approval is the point at which the year's distribution is settled: the members' entitlements are fixed and no + * further dividend can be declared out of that profit. Leaving the remainder in the Non-Distributable account + * would carry it into the next year's balance, and the next declaration would size itself on profit that had + * already been through a distribution decision — offering the same money twice. + * + * The remainder is what the whole distributable pool for the period leaves after this declaration and any others + * already made against it, which is the same figure the declaration screen showed as available. Dated to the + * period's end rather than the day of approval, so it files under the year it belongs to: a 2025-26 dividend is + * often approved months into 2026-27. + * + * Skipped silently when there is nothing left, or when the GL setup is incomplete — approval must not be blocked + * by an account that has not been mapped. */ - private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) { + private void closeOffUndistributedProfit(final ShareProductDividendPayOutDetails declaration) { + + final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate(); + if (periodEndDate == null) { + return; + } + final Map period = jdbcTemplate.queryForMap( + "SELECT dividend_period_start_date, product_id FROM m_share_product_dividend_pay_out WHERE id = ?", declaration.getId()); + final LocalDate periodStartDate = ((java.sql.Date) period.get("dividend_period_start_date")).toLocalDate(); + + final java.sql.Date from = java.sql.Date.valueOf(periodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(periodEndDate); + + final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to); + if (netProfit.signum() <= 0) { + return; + } + final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE)) + .subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE)); + + // Every declaration against an overlapping period, this one included — the pool is + // society-wide, so a second product's dividend has already consumed part of it. + final BigDecimal declaredTotal = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out + WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, BigDecimal.class, to, from); + + final BigDecimal remainder = distributable.subtract(declaredTotal); + if (remainder.signum() <= 0) { + return; + } + + final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE); + final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + if (distributableAccountId == null || settledAccountId == null) { + return; + } + + final String financialYear = financialYearOf(periodEndDate); + // Dated to the day of approval, not the period end. Closing off the remainder is an act of + // the approval, so it must appear in the ledger on the day the officer performed it — + // back-dating it to 31 March files it months before it happened, where nobody reviewing the + // approval would find it. The financial year it belongs to is carried in the description. + postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")), + "DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), + "FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #" + + declaration.getId()); + } + + private String currencyCodeOfProduct(final Object productId) { + final List> rows = jdbcTemplate.queryForList("SELECT currency_code FROM m_share_product WHERE id = ?", + productId); + return rows.isEmpty() ? "INR" : (String) rows.get(0).get("currency_code"); + } + + /** + * Records what rounding left behind, and moves it to the society's own profit. + * + * Each member's entitlement is rounded down to a whole rupee (see {@code ShareProductDividendAssembler}) because a + * dividend is paid out as a Demand Draft and a branch cannot draw one for 421.32. The paise that fall off are not + * anybody's money: rounding DOWN means no member was ever promised them, so unlike an unclaimed dividend there is + * nobody who could turn up to claim it and no reason to hold it for three years. It goes to Bank Profit now. + * + * The row, however, is permanent. It is what answers an auditor years later asking why a declaration of 1,538.91 + * only allocated 1,538 to members — per declaration, with the journal entry that moved the difference. + * + * Skipped silently if the profit GL is not configured, leaving {@code swept_to_profit_date} null so the residue can + * be identified and swept once it is — the same posture the Reserve entry above takes, so incomplete GL setup never + * blocks a declaration. + */ + private void recordAndSweepRoundingResidue(final Long dividendPayOutId, final Long productId, final String currencyCode, + final BigDecimal declaredAmount, final BigDecimal belowMinimumAmount, final LocalDate periodStartDate, + final LocalDate periodEndDate) { + + final Map allocation = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(amount), 0) AS allocated, + COUNT(*) FILTER (WHERE amount > 0) AS member_count, + COUNT(*) FILTER (WHERE amount = 0) AS below_minimum_count + FROM m_share_account_dividend_details WHERE dividend_pay_out_id = ? + """, dividendPayOutId); + final BigDecimal allocatedAmount = (BigDecimal) allocation.get("allocated"); + final int memberCount = ((Number) allocation.get("member_count")).intValue(); + // Members allocated exactly zero were dropped for falling under the minimum payable amount + // (an ineligible member never reaches allocation at all — the assembler skips them earlier), + // so this counts precisely those whose entitlement was too small to draw a draft for. + final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue(); + final BigDecimal residue = declaredAmount.subtract(allocatedAmount); + + // Swept to the settled account, not back to the pool it came from: the residue belongs to the + // year that was just declared, and returning it to the distributable balance would put it + // straight back into what the next declaration measures. + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + + // A residue of zero still gets its row: "nothing was left over" is itself the answer to the + // audit question, and its absence would be indistinguishable from the sweep never running. + // + // The row is written now, at declaration, because it records what the allocation arithmetic + // produced. The journal entry that moves the money waits for approval — see + // sweepRoundingResidueOnApproval — because it debits Dividend Payable, and that account + // carries nothing until approval creates the liability. swept_to_profit_date stays null + // until then, which is what marks a residue as recorded-but-not-yet-swept. + jdbcTemplate.update(""" + INSERT INTO m_share_dividend_rounding_residue + (dividend_pay_out_id, product_id, declared_amount, allocated_amount, residue_amount, member_count, + below_minimum_count, below_minimum_amount, currency_code, period_start_date, period_end_date, + swept_to_profit_date, gl_transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + """, dividendPayOutId, productId, declaredAmount, allocatedAmount, residue, memberCount, belowMinimumCount, + belowMinimumAmount, currencyCode, java.sql.Date.valueOf(periodStartDate), java.sql.Date.valueOf(periodEndDate)); + } + + /** + * Moves what rounding left behind to the society's own profit, once the dividend is approved. + * + * Split from the recording of the residue because this entry debits Dividend Payable, and that liability does not + * exist until approval creates it. Sweeping at declaration time would drive Dividend Payable negative — the ledger + * would show the society owing members less than nothing while the dividend was still awaiting approval. + * + * Dated to the day of approval, alongside the release to Dividend Payable it takes back from. Both are acts of the + * approval and belong on the ledger the day it happened; the financial year they relate to is carried in the + * description rather than in the entry date. + */ + private void sweepRoundingResidueOnApproval(final Long dividendPayOutId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT residue_amount, currency_code, period_end_date + FROM m_share_dividend_rounding_residue + WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL + """, dividendPayOutId); + if (rows.isEmpty()) { + return; + } + + final Map row = rows.get(0); + final BigDecimal residue = (BigDecimal) row.get("residue_amount"); + if (residue == null || residue.signum() <= 0) { + return; + } + + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + // Left unswept rather than forced through: swept_to_profit_date stays null so the residue is + // still identifiable and can be swept once the GL setup is completed. + if (bankProfitAccountId == null || payableDividendsAccountId == null) { + return; + } + + final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate(); + final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + final String transactionId = "DIVROUND" + dividendPayOutId; + + // Approval credited the whole declared amount to Dividend Payable; this takes back the slice + // that will never be paid to a member and returns it to retained profit. + postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId, + sweptDate, + "FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId); + + jdbcTemplate.update(""" + UPDATE m_share_dividend_rounding_residue + SET swept_to_profit_date = ?, gl_transaction_id = ? + WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL + """, sweptDate, transactionId, dividendPayOutId); + } + + /** + * A co-operative society may only distribute what is left of net profit after the statutory appropriations the + * Co-operative Societies Act requires first — a Statutory Reserve Fund transfer and a state-federation + * education-fund contribution — and after every dividend already declared against an overlapping period for ANY + * share product, since the profit pool is society-wide rather than per-product. Without this, two products could + * each be declared 60% of profit and the society would have committed 120% of money it never had. + * + * The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is + * enforced here because that screen is not the only way a declaration can be created, and a stale screen must not + * be able to commit an over-declaration. + * + * Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has + * an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other + * declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor. + */ + private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate, + final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) { + final java.sql.Date from = java.sql.Date.valueOf(dividendPeriodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(dividendPeriodEndDate); + + final BigDecimal netProfit = profitForPeriod(dividendPeriodStartDate, dividendPeriodEndDate, from, to); + + if (netProfit.signum() <= 0) { + throw new DividentProcessingException("no.distributable.profit", "The bank made no profit between " + dividendPeriodStartDate + + " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period."); + } + + final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE); + final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE); + final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund); + + // Every product competes for the same distributable profit, so this deliberately does NOT + // filter by product_id — unlike validateNoOverlappingDividendPeriod, which is about paying + // the same product's members twice for the same days. + final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out + WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, BigDecimal.class, to, from); + + final BigDecimal available = distributableProfit.subtract(alreadyDeclared); + if (dividendAmount.compareTo(available) > 0) { + throw new DividentProcessingException("exceeds.distributable.profit", + "This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit + + " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves " + + distributableProfit + ", of which " + alreadyDeclared + " is already declared — only " + + available.max(BigDecimal.ZERO) + " remains available."); + } + } + + /** + * The profit a dividend for this period may be declared out of. + * + * For a full financial year that has been closed, that is the balance of the Bank Profit equity GL — the audited + * figure the books settled on once every income and expense GL was closed into it. Reading the closed figure + * rather than re-deriving income-less-expense keeps the declaration tied to the books: an adjusted or reversed + * closing entry moves the dividend ceiling with it. + * + * Anything else is an interim declaration inside a year that is still open, where the live P&L is the only figure + * there is. Mirrors {@code DistributableProfitApiResource} so the screen's ceiling and this one cannot diverge. + */ + private BigDecimal profitForPeriod(final LocalDate periodStart, final LocalDate periodEnd, final java.sql.Date from, + final java.sql.Date to) { + final int fyStartYear = periodStart.getMonthValue() >= 4 ? periodStart.getYear() : periodStart.getYear() - 1; + final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100); + final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31); + + if (!periodStart.isAfter(fyStart) && !periodEnd.isBefore(fyEnd)) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount != null && closedCount > 0) { + final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + return closedProfit == null ? BigDecimal.ZERO : closedProfit; + } + } + + final Map profitRow = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, from, to); + return ((BigDecimal) profitRow.get("total_income")).subtract((BigDecimal) profitRow.get("total_expense")); + } + + /** + * The Indian financial year a date falls in, as "2026-27". A dividend is labelled by the year its period closes, + * which is how a member and an auditor both refer to it. + */ + private String financialYearOf(final LocalDate date) { + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + private BigDecimal percentageOfNetProfit(final BigDecimal netProfit, final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + final BigDecimal percentage = new BigDecimal(String.valueOf(rows.get(0).get("value"))); + return netProfit.multiply(percentage).divide(BigDecimal.valueOf(100), 6, java.math.RoundingMode.HALF_UP); + } + + /** + * Posts the statutory appropriations that must come out of profit before a dividend may be declared. + * + *
+     *   Dr  Bank Profit               Cr  Statutory Reserve Fund   (25% of net profit, equity — retained permanently)
+     *   Dr  Bank Profit               Cr  Education Fund Payable   (1%, a liability owed to the state federation)
+     * 
+ * + * Until now these only ever reduced the ceiling the declaration screen enforced — the money was never actually + * moved, so the Statutory Reserve Fund and Education Fund GLs stayed empty and the balance sheet did not show the + * transfers the Co-operative Societies Act requires. Posting them here makes the appropriation real. + * + * The rates come from c_configuration (changelog 0478), so a society on a different state Act, or one whose rates + * are revised, changes the setting rather than the code. A disabled or missing row means that appropriation does not + * apply and nothing is posted for it. + * + * Posted once per declaration and only for the appropriations that resolve — a society whose GL setup is incomplete + * still gets its dividend declared rather than being blocked. + */ + private void postStatutoryAppropriations(final Long dividendPayOutId, final String currencyCode, final LocalDate periodStartDate, + final LocalDate periodEndDate, final String financialYear) { + + final java.sql.Date from = java.sql.Date.valueOf(periodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(periodEndDate); + final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to); + if (netProfit.signum() <= 0) { + return; + } + + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); + if (bankProfitAccountId == null) { + return; + } + + postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(STATUTORY_RESERVE_GL_CODE), + percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE), currencyCode, financialYear, + "statutory reserve", "DIVSTATRES"); + postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(EDUCATION_FUND_GL_CODE), + percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE), currencyCode, financialYear, + "education fund", "DIVEDUFUND"); + } + + /** One appropriation out of profit: debit Bank Profit, credit the fund it is set aside into. */ + private void postAppropriation(final Long dividendPayOutId, final Long bankProfitAccountId, final Long fundAccountId, + final BigDecimal amount, final String currencyCode, final String financialYear, final String label, + final String transactionIdPrefix) { + + if (fundAccountId == null || amount.signum() <= 0) { + return; + } + final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + postJournalEntryPair(bankProfitAccountId, fundAccountId, amount, currencyCode, transactionIdPrefix + dividendPayOutId, entryDate, + "FY " + financialYear + " " + label + " appropriated from profit — declaration #" + dividendPayOutId); + } + + private Long glAccountIdByCode(final String glCode) { + return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst() + .orElse(null); + } + + /** + * Declaring a dividend charges it against the society's retained profit and creates the obligation to members. + * + *
+     *   Dr  Bank Profit                 Cr  Reserve for Dividend   — profit set aside for this declaration
+     *   Dr  Reserve for Dividend        Cr  Dividend Payable       — released as what the society owes members
+     * 
+ * + * Routed through Reserve for Dividend rather than posted straight across, so the appropriation of profit and the + * obligation to members are two separate, visible acts — which is how a board minutes them. Reserve nets to nil + * once both legs post; its credit column is the cumulative profit ever set aside for dividends. + * + * The money still starts in Bank Profit and, if never claimed, returns there via Dividend Forfeited Income at + * year-end closing, so the round trip reconciles. + * + * Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which + * expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting + * Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by + * incomplete GL setup. + */ + private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount, + final String financialYear) { + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + final Long reserveAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) + .stream().findFirst().orElse(null); + if (bankProfitAccountId == null || payableDividendsAccountId == null) { + return; + } + + final AppUser currentUser = this.context.authenticatedUser(); + final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + final String transactionId = "DIVDECL" + dividendPayOutId; + // The financial year is stamped into every dividend journal entry so the ledger itself can + // answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared + // across years, and without the year on the line the balances only ever give a running total. + final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId; + + if (reserveAccountId == null) { + postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate, + description); + return; + } + + // Only the appropriation posts here. The release to members is a separate act that waits for + // approval — see releaseReserveToDividendPayable. Posting both at declaration would put a + // liability to members on the books before anyone had approved paying them. + postJournalEntryPair(bankProfitAccountId, reserveAccountId, amount, currencyCode, transactionId, sqlEntryDate, + description + " (profit set aside into Reserve)"); + } + + /** + * The second leg of the dividend appropriation, posted on approval: + * + *
+     *   Dr  Reserve for Dividend        Cr  Dividend Payable       — released as what the society owes members
+     * 
+ * + * Split from the declaration leg deliberately. A declared dividend is a proposal: profit is set aside, but the + * society does not yet owe anyone anything. The obligation to members only exists once the board has approved it, + * so Dividend Payable — a liability — must not appear on the balance sheet before that. Between declaration and + * approval the amount sits visibly in Reserve for Dividend, which is exactly what that account is for. + * + * Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside. + */ + private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) { final Long reserveAccountId = jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) @@ -223,27 +689,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) .stream().findFirst().orElse(null); + + // With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend + // Payable, so the liability is on the books and there is nothing left to release. if (reserveAccountId == null || payableDividendsAccountId == null) { return; } + // Read back from the row rather than the entity: the amount and product are not exposed as + // getters, and this matches how closeOffUndistributedProfit reads the same declaration. + final Map row = jdbcTemplate.queryForMap( + "SELECT amount, product_id, dividend_period_end_date FROM m_share_product_dividend_pay_out WHERE id = ?", + declaration.getId()); + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount == null || amount.signum() <= 0) { + return; + } + + final ShareProduct product = this.repository.findOneWithNotFoundDetection(((Number) row.get("product_id")).longValue()); + final String financialYear = financialYearOf(((java.sql.Date) row.get("dividend_period_end_date")).toLocalDate()); + final String description = "FY " + financialYear + " dividend approved — declaration #" + declaration.getId() + + " (Reserve released to members)"; + + postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(), + "DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description); + } + + /** + * Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction + * the money travelled and the ledger reads in order. + */ + private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, + final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) { + final AppUser currentUser = this.context.authenticatedUser(); - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); - final String transactionId = "DIVRES" + dividendPayOutId; - final String description = "Reserve for Dividend declaration #" + dividendPayOutId; - final String insertSql = """ - INSERT INTO acc_gl_journal_entry - (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, - description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc, - submitted_on_date, transaction_date) - VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) - """; - // Debit reduces the equity Reserve; credit increases the Dividend Payable liability. - jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, + amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); + jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, + amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); } @Override @@ -256,7 +739,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } dividendPayOutDetails.approveDividendPayout(); this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails); + releaseReserveToDividendPayable(dividendPayOutDetails); + sweepRoundingResidueOnApproval(PayOutDetailId); makeDividendsClaimable(PayOutDetailId); + closeOffUndistributedProfit(dividendPayOutDetails); return new CommandProcessingResultBuilder() // .withEntityId(PayOutDetailId) // .build(); @@ -267,16 +753,14 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account. - * Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at - * declaration time) until the member actually comes to claim it -- at which point it can be - * split between clearing an overdue loan, a savings credit, or cash (see - * {@code DividendClaimRequestApiResource}). This just marks each member's share of the - * declaration POSTED (their entitlement is locked in, no more recalculation) and opens a - * 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather - * than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This - * is cheap enough (no per-member external command calls, just two bulk statements) to run - * synchronously in the approve request itself -- no background job needed. + * Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account. Every member's + * entitlement stays purely a "Dividend Payable" GL liability (already posted at declaration time) until the member + * actually comes to claim it -- at which point it can be split between clearing an overdue loan, a savings credit, + * or cash (see {@code DividendClaimRequestApiResource}). This just marks each member's share of the declaration + * POSTED (their entitlement is locked in, no more recalculation) and opens a 3-year window (see + * {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather than a savings hold) after which an + * unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls, + * just two bulk statements) to run synchronously in the approve request itself -- no background job needed. */ private void makeDividendsClaimable(final Long payOutDetailId) { final LocalDate today = DateUtils.getBusinessLocalDate(); @@ -337,8 +821,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_SHARE, productId, maker); - final java.util.Set submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command, pendingEdit.getShareOnlyFields()); - final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command, pendingEdit.getGlAccountMappingFields()); + final java.util.Set submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command, + pendingEdit.getShareOnlyFields()); + final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command, + pendingEdit.getGlAccountMappingFields()); if (glMappingSubmitted) { submitted.add("glAccountMapping"); } 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 3dce44518..25c028700 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 @@ -66,8 +66,8 @@ public class ShareProductsConfiguration { @Bean @ConditionalOnMissingBean(ShareProductDividendAssembler.class) public ShareProductDividendAssembler shareProductDividendAssembler(ShareProductReadPlatformService shareProductReadPlatformService, - ShareAccountReadPlatformService shareAccountReadPlatformService) { - return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService); + ShareAccountReadPlatformService shareAccountReadPlatformService, JdbcTemplate jdbcTemplate) { + return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService, jdbcTemplate); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java new file mode 100644 index 000000000..2c45c36d1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.voucher.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Physical verification of a voucher against the paper slip, and the files scanned in alongside it. + * + * The counter reads the signed slip, compares it with what the system recorded, and marks the + * voucher VERIFIED or — when the two disagree — EXCEPTION, carrying the amount the slip actually + * shows and a remark. Whoever investigates then has both figures and the scanned slip in one place, + * instead of having to work out whether the system posted the wrong amount or the slip was written + * up wrongly. + */ +@Path("/v1/vouchers") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class VoucherVerificationApiResource { + + private static final String STATUS_VERIFIED = "VERIFIED"; + private static final String STATUS_EXCEPTION = "EXCEPTION"; + + /** Guards against a scanned slip large enough to strain the request or the row. */ + private static final long MAX_ATTACHMENT_BYTES = 10L * 1024 * 1024; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ── POST /v1/vouchers/{voucherNo}/verify ────────────────────────────────── + // The voucher number travels in the body, not the path: numbers are formatted + // CR/001/2108/0001 and Spring Security's strict firewall rejects the encoded slashes. + + @POST + @Path("verify") + @Consumes(MediaType.APPLICATION_JSON) + public String verify(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final var root = this.fromJsonHelper.parse(body); + + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + + String status = this.fromJsonHelper.extractStringNamed("status", root); + status = status == null ? "" : status.trim().toUpperCase(); + if (!STATUS_VERIFIED.equals(status) && !STATUS_EXCEPTION.equals(status)) { + throw new PlatformDataIntegrityException("error.msg.voucher.verify.status.invalid", + "Status must be either VERIFIED or EXCEPTION."); + } + + final BigDecimal slipAmount = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("slipAmount", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + + // An exception always has to say why, because it is a flag someone else has to act on. + // It does not always mean the amounts differ: a voucher whose figures agree can still be + // wrong — an unsigned slip, the wrong account, a member who disputes the entry — so the + // slip amount is recorded rather than demanded as proof of a difference. + if (STATUS_EXCEPTION.equals(status) && (remarks == null || remarks.isBlank())) { + throw new PlatformDataIntegrityException("error.msg.voucher.verify.remarks.required", + "A remark is required when raising an exception, so it can be investigated."); + } + + final BigDecimal systemAmount = findVoucherAmount(voucherNo); + if (systemAmount == null) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in any register."); + } + + // Upsert: a voucher carries exactly one verdict, so re-checking a slip corrects the earlier + // one rather than leaving two rows to disagree with each other. + jdbcTemplate.update(""" + INSERT INTO m_voucher_verification + (voucher_no, status, system_amount, slip_amount, remarks, verified_by, verified_by_name, verified_at) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT (voucher_no) DO UPDATE SET + status = EXCLUDED.status, + system_amount = EXCLUDED.system_amount, + slip_amount = EXCLUDED.slip_amount, + remarks = EXCLUDED.remarks, + verified_by = EXCLUDED.verified_by, + verified_by_name = EXCLUDED.verified_by_name, + verified_at = EXCLUDED.verified_at + """, + voucherNo, status, systemAmount, + STATUS_EXCEPTION.equals(status) ? slipAmount : null, + remarks, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + + final JsonObject res = new JsonObject(); + res.addProperty("voucherNo", voucherNo); + res.addProperty("status", status); + res.addProperty("systemAmount", systemAmount); + // Only reported when a slip figure was actually supplied — an exception raised over + // something other than the amount carries none, and there is no difference to state. + if (STATUS_EXCEPTION.equals(status) && slipAmount != null) { + res.addProperty("slipAmount", slipAmount); + res.addProperty("difference", slipAmount.subtract(systemAmount)); + } + return gson.toJson(res); + } + + // ── Exception queue, for whoever has to sort them out ───────────────────── + + /** + * Open exceptions across every voucher, newest first — the queue a clerk or manager works + * through. Not scoped to a single day: an exception raised on Friday still needs answering on + * Monday, so limiting this to today would quietly hide the ones that matter most. + * + * Each row carries both amounts, the cashier's remark and the account it belongs to, so whoever + * picks it up can sit down with the cashier already knowing what to look at. + */ + @GET + @Path("exceptions") + public String exceptions(@QueryParam("status") final String status) { + final AppUser currentUser = this.context.authenticatedUser(); + + // Anyone who can see a voucher can see that it was questioned; only a checker may close it. + final boolean openOnly = status == null || status.isBlank() || "OPEN".equalsIgnoreCase(status); + final String resolutionFilter = openOnly ? " AND v.resolution_status IS NULL" + : ("RESOLVED".equalsIgnoreCase(status) ? " AND v.resolution_status IS NOT NULL" : ""); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT v.voucher_no, v.system_amount, v.slip_amount, v.remarks, + v.verified_by_name, v.verified_at, + v.resolution_status, v.resolution_notes, v.resolved_by_name, v.resolved_at, + COALESCE(d.account_no, w.account_no, b.gl_account_no) AS account_no, + COALESCE(d.client_name, w.client_name, b.gl_account_name) AS party, + COALESCE(d.cashier_name, w.clerk_name, b.clerk_name) AS raised_against, + COALESCE(va.n, 0) AS attachment_count + FROM m_voucher_verification v + LEFT JOIN m_cashier_deposit_request d ON d.voucher_no = v.voucher_no + LEFT JOIN m_clerk_withdrawal_request w ON w.voucher_no = v.voucher_no + LEFT JOIN m_clerk_disbursement_request b ON b.voucher_no = v.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = v.voucher_no + WHERE v.status = 'EXCEPTION'""" + resolutionFilter + """ + ORDER BY v.verified_at DESC + """); + + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", r.get("voucher_no")); + m.put("systemAmount", r.get("system_amount")); + m.put("slipAmount", r.get("slip_amount")); + m.put("remarks", r.get("remarks")); + m.put("raisedByName", r.get("verified_by_name")); + m.put("raisedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); + m.put("accountNo", r.get("account_no")); + m.put("party", r.get("party")); + m.put("raisedAgainst", r.get("raised_against")); + m.put("attachmentCount", r.get("attachment_count")); + m.put("resolutionStatus", r.get("resolution_status")); + m.put("resolutionNotes", r.get("resolution_notes")); + m.put("resolvedByName", r.get("resolved_by_name")); + m.put("resolvedAt", r.get("resolved_at") == null ? null : r.get("resolved_at").toString()); + out.add(m); + } + return gson.toJson(Map.of("canResolve", isCheckerRole(currentUser), "exceptions", out, "count", out.size())); + } + + /** + * Closes an exception with a conclusion. Open to a clerk, head cashier or manager — but not to + * the cashier who raised the flag, who must not be the one who decides it was nothing. + */ + @POST + @Path("exceptions/resolve") + @Consumes(MediaType.APPLICATION_JSON) + public String resolveException(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + if (!isCheckerRole(currentUser)) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolve.forbidden", + "Only a Clerk, Head Cashier or Bank Manager can close a voucher exception."); + } + + final var root = this.fromJsonHelper.parse(body); + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + String resolution = this.fromJsonHelper.extractStringNamed("resolutionStatus", root); + final String notes = this.fromJsonHelper.extractStringNamed("resolutionNotes", root); + + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + resolution = resolution == null ? "" : resolution.trim().toUpperCase(); + if (!List.of("RESOLVED_SYSTEM_ERROR", "RESOLVED_SLIP_ERROR", "RESOLVED_NO_ACTION").contains(resolution)) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolution.invalid", + "Choose whether the system entry was wrong, the slip was wrong, or no action is needed."); + } + if (notes == null || notes.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.notes.required", + "Record what was found and what was done about it."); + } + + // Closing an exception needs paper behind it — the corrected slip, a signed note, whatever + // was relied on. Without that the resolution is one person's word, which is precisely what + // the exception was raised to avoid. Upload happens first, then the close. + final Integer attachments = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_voucher_attachment WHERE voucher_no = ?", Integer.class, voucherNo); + if (attachments == null || attachments == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.document.required", + "Attach the supporting document for this resolution before closing the exception."); + } + + final int updated = jdbcTemplate.update(""" + UPDATE m_voucher_verification + SET resolution_status = ?, resolution_notes = ?, resolved_by = ?, + resolved_by_name = ?, resolved_at = ? + WHERE voucher_no = ? AND status = 'EXCEPTION' + """, + resolution, notes, currentUser.getId(), currentUser.getDisplayName(), + Timestamp.from(Instant.now()), voucherNo); + + if (updated == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.not.found", + "No open exception found for voucher " + voucherNo + "."); + } + return gson.toJson(Map.of("voucherNo", voucherNo, "resolutionStatus", resolution, "resolved", true)); + } + + /** + * Who may close an exception: a clerk, head cashier or manager. The clerk is included because + * most differences are settled at the counter between the two of them — sending every one up to + * a manager would leave routine keying slips sitting open for days. + * + * The cashier who raised the flag is still not among them: whoever spots a difference should not + * also be the one who rules it was nothing. + */ + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk")); + } + + // There is deliberately no endpoint to clear a verdict. Deleting one would delete the evidence + // that a voucher was ever questioned, which is the opposite of what an investigation needs — a + // wrong verdict is corrected by re-verifying (the upsert above) or by raising an exception + // against it, both of which leave a record of who decided what. + + // ── Attachments ─────────────────────────────────────────────────────────── + + /** + * Accepts one scanned slip as base64 in a JSON body rather than a multipart upload: the + * browser already holds the file as a data URL from the file input, and this keeps the call on + * the same JSON path as every other request the screen makes. + */ + @POST + @Path("attachments") + @Consumes(MediaType.APPLICATION_JSON) + public String upload(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final var root = this.fromJsonHelper.parse(body); + + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + final String fileName = this.fromJsonHelper.extractStringNamed("fileName", root); + final String contentType = this.fromJsonHelper.extractStringNamed("contentType", root); + final String description = this.fromJsonHelper.extractStringNamed("description", root); + String base64 = this.fromJsonHelper.extractStringNamed("fileData", root); + + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + if (fileName == null || fileName.isBlank() || base64 == null || base64.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.file.required", + "A file name and file contents are required."); + } + if (findVoucherAmount(voucherNo) == null) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in any register."); + } + + // A file input hands over "data:image/png;base64,AAAA..." — keep only the payload. + final int comma = base64.indexOf(','); + if (base64.startsWith("data:") && comma > 0) { + base64 = base64.substring(comma + 1); + } + + final byte[] data; + try { + data = java.util.Base64.getDecoder().decode(base64); + } catch (final IllegalArgumentException e) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.invalid", + "The file could not be read. Please try uploading it again."); + } + if (data.length == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.empty", "The file is empty."); + } + if (data.length > MAX_ATTACHMENT_BYTES) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.too.large", + "The file is larger than 10 MB. Please scan it at a lower resolution."); + } + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_attachment + (voucher_no, file_name, content_type, file_size, description, file_data, + uploaded_by, uploaded_by_name, uploaded_at) + VALUES (?,?,?,?,?,?,?,?,?) + RETURNING id + """, + Long.class, voucherNo, fileName, contentType, (long) data.length, description, data, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + + return gson.toJson(Map.of("resourceId", id, "voucherNo", voucherNo, "fileName", fileName, "fileSize", data.length)); + } + + /** Metadata only — the bytes are fetched one at a time by {@link #download}. */ + @GET + @Path("attachments") + public String list(@QueryParam("voucherNo") final String voucherNo) { + this.context.authenticatedUser(); + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, voucher_no, file_name, content_type, file_size, description, + uploaded_by_name, uploaded_at + FROM m_voucher_attachment + WHERE voucher_no = ? + ORDER BY id DESC + """, voucherNo); + + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("voucherNo", r.get("voucher_no")); + m.put("fileName", r.get("file_name")); + m.put("contentType", r.get("content_type")); + m.put("fileSize", r.get("file_size")); + m.put("description", r.get("description")); + m.put("uploadedByName", r.get("uploaded_by_name")); + final Object at = r.get("uploaded_at"); + m.put("uploadedAt", at == null ? null : at.toString()); + out.add(m); + } + return gson.toJson(Map.of("voucherNo", voucherNo, "attachments", out)); + } + + @GET + @Path("attachments/{id}/download") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response download(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate + .queryForList("SELECT file_name, content_type, file_data FROM m_voucher_attachment WHERE id = ?", id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found", + "Attachment " + id + " does not exist."); + } + final Map row = rows.get(0); + final byte[] data = (byte[]) row.get("file_data"); + final String contentType = row.get("content_type") != null ? (String) row.get("content_type") + : MediaType.APPLICATION_OCTET_STREAM; + return Response.ok(data, contentType) + .header("Content-Disposition", "attachment; filename=\"" + row.get("file_name") + "\"").build(); + } + + @DELETE + @Path("attachments/{id}") + public String delete(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + final int removed = jdbcTemplate.update("DELETE FROM m_voucher_attachment WHERE id = ?", id); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found", + "Attachment " + id + " does not exist."); + } + return gson.toJson(Map.of("resourceId", id, "deleted", true)); + } + + /** + * The amount the system holds for a voucher, looked up across all three registers. Returns + * null when no register knows the number, which is how callers detect a bad voucher. + */ + private BigDecimal findVoucherAmount(final String voucherNo) { + final List found = jdbcTemplate.query(""" + SELECT amount FROM m_cashier_deposit_request WHERE voucher_no = ? + UNION ALL + SELECT amount FROM m_clerk_withdrawal_request WHERE voucher_no = ? + UNION ALL + SELECT dr_amount FROM m_clerk_disbursement_request WHERE voucher_no = ? + """, (rs, n) -> rs.getBigDecimal(1), voucherNo, voucherNo, voucherNo); + return found.isEmpty() ? null : found.get(0); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index c243e9034..32bb2cbd8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -19,22 +19,35 @@ package org.apache.fineract.portfolio.voucher.service; import java.math.BigDecimal; +import java.sql.Date; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; /** * Central voucher register (m_voucher). Every maker-checker cash transaction - * (clerk withdrawal request, cashier deposit request) gets a globally unique - * voucher number from this single sequence, so a withdrawal and a deposit can - * never share the same voucher number. The voucher is also stamped onto the - * resulting savings transaction (m_savings_account_transaction.voucher_no) - * once the manager approves and the transaction is created. + * (clerk withdrawal request, cashier deposit request) gets a voucher number in + * the form PREFIX/BRANCH/DDMM/NNNN — for example CR/001/2108/0001 for a deposit + * and DR/001/2108/0001 for a withdrawal on 21 August at branch 001. + * + * The trailing counter restarts at 0001 every day, independently for each + * (prefix, branch) pair, so a deposit and a withdrawal at the same branch on the + * same day never share a number, and neither does the same direction at another + * branch. Counters live in m_voucher_sequence and are bumped under a row lock, + * because a daily-reset counter cannot lean on the m_voucher autoincrement PK + * for uniqueness the way the old flat "000001" scheme did. + * + * The voucher is also stamped onto the resulting savings transaction + * (m_savings_account_transaction.voucher_no) once the manager approves and the + * transaction is created. */ @Service @RequiredArgsConstructor @@ -46,30 +59,90 @@ public class VoucherService { public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; + public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; + + /** Money into an account — the CR side of the slip. */ + public static final String PREFIX_CREDIT = "CR"; + /** Money out of an account — the DR side of the slip. */ + public static final String PREFIX_DEBIT = "DR"; + + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; /** - * Creates a voucher row and returns the formatted voucher number ("000001"). - * The number is the zero-padded primary key, so uniqueness is guaranteed by - * the database sequence — no counter table, no race conditions. + * Maps a voucher type to its slip-side prefix. A deposit credits the member's + * account (CR); a withdrawal and a disbursement both take money out (DR). + */ + private static String prefixFor(final String voucherType) { + return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT; + } + + /** Branch code as it appears in the UI's BRANCH dropdown: office id zero-padded to 3 digits. */ + private static String branchCode(final Long officeId) { + return String.format("%03d", officeId != null ? officeId : 1L); + } + + /** + * Reserves the next counter value for one (prefix, branch, day) series. + * + * The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use + * of the day and increments it thereafter. Being a single statement, it takes + * and releases the row lock atomically, so two cashiers submitting at the same + * moment are serialised by the database and can never be handed the same + * number — no explicit SELECT ... FOR UPDATE needed. + */ + private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) { + return jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number) + VALUES (?,?,?,1) + ON CONFLICT (prefix, branch_code, series_date) + DO UPDATE SET last_number = m_voucher_sequence.last_number + 1 + RETURNING last_number + """, + Integer.class, prefix, branch, Date.valueOf(day)); + } + + /** + * Creates a voucher row and returns the formatted voucher number, e.g. + * "CR/001/2108/0001". The branch is taken from the acting user's own office + * rather than from any branch the screen happens to have selected, so the + * voucher always names the branch that actually handled the cash. */ public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user) { + return createVoucher(voucherType, referenceType, referenceId, amount, user, prefixFor(voucherType)); + } - final Long voucherId = jdbcTemplate.queryForObject(""" + /** + * As {@link #createVoucher}, but with the slip side stated explicitly. The + * disbursement register covers three directions under one voucher type — + * money out of a GL (DISBURSEMENT), money into a GL (COLLECTION) and an + * account-to-account move (BANK_TRANSFER) — so the caller decides whether + * this one reads as CR or DR rather than inferring it from the type alone. + */ + public String createVoucher(final String voucherType, final String referenceType, + final Long referenceId, final BigDecimal amount, final AppUser user, + final String prefix) { + + // Tenant timezone, not the server's — the day the counter resets on has to be the + // branch's business day, otherwise a late-evening voucher lands in the wrong series. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null); + final int serial = nextSeriesNumber(prefix, branch, today); + final String voucherNo = String.format("%s/%s/%s/%04d", + prefix, branch, today.format(DAY_MONTH), serial); + + jdbcTemplate.update(""" INSERT INTO m_voucher - (voucher_type, reference_type, reference_id, amount, created_by, created_by_username, created_at) - VALUES (?,?,?,?,?,?,?) - RETURNING id + (voucher_no, voucher_type, reference_type, reference_id, amount, + created_by, created_by_username, created_at) + VALUES (?,?,?,?,?,?,?,?) """, - Long.class, - voucherType, referenceType, referenceId, amount, + voucherNo, voucherType, referenceType, referenceId, amount, user.getId(), user.getUsername(), Timestamp.from(Instant.now()) ); - final String voucherNo = String.format("%06d", voucherId); - jdbcTemplate.update("UPDATE m_voucher SET voucher_no = ? WHERE id = ?", voucherNo, voucherId); return voucherNo; } diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 18fcdd30f..b68204e8a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} 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 d2407861e..11ea86186 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 @@ -537,4 +537,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml new file mode 100644 index 000000000..608897382 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml new file mode 100644 index 000000000..a3a1371e6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml new file mode 100644 index 000000000..159d475fd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml new file mode 100644 index 000000000..1655d38c0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml new file mode 100644 index 000000000..e8344dc7d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml new file mode 100644 index 000000000..64a2da40c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml new file mode 100644 index 000000000..3484b39c9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml new file mode 100644 index 000000000..df54c2134 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml new file mode 100644 index 000000000..6ca3d9723 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml @@ -0,0 +1,116 @@ + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code IN ('STATRES01', 'EDUFUND01') + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name IN ('dividend-statutory-reserve-percentage', + 'dividend-education-fund-percentage', 'dividend-maximum-rate-percentage') + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml new file mode 100644 index 000000000..9d25dd8cd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml new file mode 100644 index 000000000..ca8b4d3cc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-minimum-payable-amount' + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-rounding-multiple' + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml new file mode 100644 index 000000000..4029b4111 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml @@ -0,0 +1,68 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVPAID01' + + + + + + + + + + + + + INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type) + SELECT id, 202 FROM acc_gl_account WHERE gl_code = 'DIVPAID01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml new file mode 100644 index 000000000..71e9e7b21 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CREATE UNIQUE INDEX uq_ssdr_one_pending_per_account + ON m_share_savings_debit_request (share_account_id) + WHERE status = 'PENDING'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml new file mode 100644 index 000000000..9b22d3f68 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml @@ -0,0 +1,54 @@ + + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE value = 'Share Application Settlement' + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml new file mode 100644 index 000000000..722d9e079 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_account_transaction SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_clerk_withdrawal_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_cashier_deposit_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_clerk_disbursement_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_share_advance_collection SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + DELETE FROM m_voucher WHERE voucher_no ~ '^[0-9]{6}$' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml new file mode 100644 index 000000000..baad9edd7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml @@ -0,0 +1,36 @@ + + + + + + + + UPDATE m_share_account_charge + SET amount = charge_amount_or_percentage, + amount_outstanding_derived = charge_amount_or_percentage, + is_paid_derived = false + WHERE charge_calculation_enum = 1 + AND COALESCE(amount, 0) = 0 + AND COALESCE(charge_amount_or_percentage, 0) > 0 + AND COALESCE(amount_paid_derived, 0) = 0 + AND COALESCE(amount_waived_derived, 0) = 0 + AND COALESCE(amount_writtenoff_derived, 0) = 0 + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml new file mode 100644 index 000000000..5a24ee0b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml @@ -0,0 +1,44 @@ + + + + + + + + UPDATE m_share_account_charge c + SET amount_paid_derived = 0, + amount_outstanding_derived = c.amount, + is_paid_derived = false + WHERE c.charge_time_enum = 13 + AND c.is_active = true + AND c.is_paid_derived = true + AND COALESCE(c.amount, 0) > 0 + AND COALESCE(c.amount_waived_derived, 0) = 0 + AND COALESCE(c.amount_writtenoff_derived, 0) = 0 + AND EXISTS ( + SELECT 1 FROM m_share_account sa + WHERE sa.id = c.account_id + AND COALESCE(sa.collection_applied, false) = false + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml new file mode 100644 index 000000000..cec1d981e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml new file mode 100644 index 000000000..d9540df3b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml new file mode 100644 index 000000000..c6d163e48 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_BRANCH_OPS' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'MENU_BRANCH_OPS' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) LIKE '%clerk%' + OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml new file mode 100644 index 000000000..d567c843d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml @@ -0,0 +1,76 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01' + + + + + + + + + + + + + + + + + + gl_code = 'BANKPROFIT01' + + + +