From 29677e610c4a7c2519b5d80dcfd50c580b310fa5 Mon Sep 17 00:00:00 2001 From: keshav Date: Sun, 30 Aug 2026 11:10:42 +0530 Subject: [PATCH] done --- ...WritePlatformServiceJpaRepositoryImpl.java | 65 ++- .../AccountingJournalEntryConfiguration.java | 6 +- .../api/DistributableProfitApiResource.java | 50 +- .../api/CashierDepositRequestApiResource.java | 72 ++- .../ShareAdvanceCollectionApiResource.java | 2 +- .../ChequeBookIssuanceChargeService.java | 56 ++- .../service/ClearingJournalService.java | 95 +++- .../ClerkDisbursementRequestApiResource.java | 108 ++++- .../ClerkWithdrawalRequestApiResource.java | 37 ++ .../CtsClearingWritePlatformServiceImpl.java | 2 +- .../api/DividendClaimRequestApiResource.java | 110 ++++- .../api/DividendDashboardApiResource.java | 154 +++++- ...WritePlatformServiceJpaRepositoryImpl.java | 71 +++ .../starter/LoanAccountConfiguration.java | 6 +- .../service/NachWritePlatformServiceImpl.java | 4 +- .../SavingsDailyInterestOrchestrator.java | 135 ++++++ .../SavingsDailyInterestService.java | 445 ++++++++++++++++++ .../SavingsInterestCycleCalculator.java | 146 ++++++ .../SavingsPooledInterestPostingService.java | 300 ++++++++++++ .../api/SavingsDailyInterestApiResource.java | 178 +++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 101 +++- .../savings/starter/SavingsConfiguration.java | 8 +- .../ShareAccountSchedularServiceImpl.java | 27 +- .../api/AllProductsDividendApiResource.java | 153 +++--- .../api/ShareCapitalReturnApiResource.java | 260 ++++++++++ ...hareDividendCapitalPreviewApiResource.java | 7 +- .../ShareProductDividendAssembler.java | 7 +- ...WritePlatformServiceJpaRepositoryImpl.java | 213 +++++---- ...DIssueRequestWritePlatformServiceImpl.java | 200 +++++++- .../api/VoucherVerificationApiResource.java | 223 ++++++++- .../voucher/service/VoucherLine.java | 95 ++++ .../voucher/service/VoucherService.java | 333 +++++++++++-- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 11 +- .../tenant/final-changelog-tenant.xml | 4 + ...0494_drop_dividend_forfeited_income_gl.xml | 63 +++ .../parts/0495_voucher_no_drop_branch.xml | 79 ++++ .../tenant/parts/0496_create_voucher_line.xml | 101 ++++ .../parts/0497_voucher_reference_key.xml | 45 ++ ...add_rate_percentage_to_dividend_payout.xml | 84 ++++ ...d_savings_interest_posting_control_gls.xml | 97 ++++ ...7_create_savings_daily_interest_ledger.xml | 239 ++++++++++ ...eate_savings_interest_posting_schedule.xml | 124 +++++ ...19_post_savings_interest_at_period_end.xml | 64 +++ 44 files changed, 4265 insertions(+), 319 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index dad040d9f..2f79ebae8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -104,6 +105,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.NonTransientDataAccessException; @@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa private final ExternalAssetOwnerRepository externalAssetOwnerRepository; private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository; private final LoanTransactionRepository loanTransactionRepository; + private final VoucherService voucherService; @Transactional @Override @@ -232,14 +236,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa } + // Raise the voucher from the entries that were actually written, rather than from the + // command: an accounting rule can supply a side the request never named, so reading + // back by transaction id is the only view that matches what the ledger now holds. + final String voucherNo = raiseJournalVoucher(transactionId); + if (voucherNo != null) { + changes.put("voucherNo", voucherNo); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId) - .withTransactionId(transactionId).build(); + .withTransactionId(transactionId).with(changes).build(); } catch (final JpaSystemException | DataIntegrityViolationException dve) { final Throwable throwable = dve.getMostSpecificCause(); throw handleJournalEntryDataIntegrityIssues(throwable, dve); } } + /** + * Records a manual journal in the voucher register and returns its number. + * + * The sides are read back from the entries just written rather than taken from the request, + * because an accounting rule can contribute a debit or credit the caller never named; only the + * persisted rows show what the ledger actually holds. + * + * A failure here does not fail the journal. The entries are already posted and correct, and + * refusing them because the register could not be written would reject a sound accounting + * transaction over its paperwork. The problem is logged and the journal stands without a + * voucher, which reads as a gap in the register rather than as a missing ledger entry. + */ + private String raiseJournalVoucher(final String transactionId) { + // A register that posts its ledger movement through this command raises its own voucher, + // naming the member and the instrument. Raising a journal one beside it would put the same + // money in the register twice. + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final List entries = this.glJournalEntryRepository + .findUnReversedManualJournalEntriesByTransactionId(transactionId); + if (CollectionUtils.isEmpty(entries)) { + return null; + } + + final List lines = new ArrayList<>(); + for (final JournalEntry entry : entries) { + final GLAccount account = entry.getGlAccount(); + final Long accountId = account == null ? null : account.getId(); + final String name = account == null ? null : account.getName(); + final String code = account == null ? null : account.getGlCode(); + + final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount()) + : VoucherLine.creditGl(accountId, name, code, entry.getAmount()); + lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription())); + } + + // Debits first, so the voucher prints the way a slip is read. + lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed()); + + final AppUser currentUser = this.context.authenticatedUser(); + return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null, + transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for journal transaction {}", transactionId, e); + return null; + } + } + private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount, final SingleDebitOrCreditEntryCommand[] creditOrDebits) { /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java index 57c1a1f2a..70df94911 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java @@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration { ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService, ExternalAssetOwnerRepository externalAssetOwnerRepository, LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository, - LoanTransactionRepository loanTransactionRepository) { + LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) { return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository, officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory, accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository, glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService, financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService, - accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository); + accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository, + voucherService); } } 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 index 1e03e915e..afb530e25 100644 --- 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 @@ -41,19 +41,21 @@ 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. + * How much profit is available to fund a share dividend, 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. + * This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has + * to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared + * for another share product over the same period, since they all draw on the same balance. * - * 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. + * The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution + * screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a + * second time would hold back money that has already left the account. + * + * Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling. */ @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.") +@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.") @RequiredArgsConstructor public class DistributableProfitApiResource { @@ -66,8 +68,6 @@ public class DistributableProfitApiResource { */ 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; @@ -103,19 +103,14 @@ public class DistributableProfitApiResource { 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; + // The statutory reserve and education fund are NOT deducted here. They are appropriated on + // the profit-distribution screen before any dividend is declared, so the profit this reads + // has already had them taken out — subtracting them again would hold back money that is no + // longer in the account. A loss-making period funds nothing, so it collapses to zero rather + // than a negative figure a caller could read as a credit. + final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO; final List> existingDeclarations = declarationsOverlapping(from, to, excludeProductId); BigDecimal alreadyDeclared = BigDecimal.ZERO; @@ -137,10 +132,6 @@ public class DistributableProfitApiResource { // 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); @@ -217,9 +208,8 @@ public class DistributableProfitApiResource { } /** - * 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. + * Every dividend already declared against a period overlapping this one, across ALL share products — the profit is + * society-wide, so a second product's declaration competes for the same balance as the first. * * excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping * declaration is separately blocked outright. @@ -267,10 +257,6 @@ public class DistributableProfitApiResource { 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/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 17115fd52..ccf2b7033 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -70,6 +73,10 @@ import com.google.common.base.Splitter; @lombok.extern.slf4j.Slf4j public class CashierDepositRequestApiResource { + // Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the + // activity JsonArray, and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -421,6 +428,10 @@ public class CashierDepositRequestApiResource { } } + // The sides of the slip, recorded now rather than at submission: which accounts the money + // actually moved between is only settled once the request is approved and posted. + addDepositVoucherLines(record, savingsAccountId, amount); + final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null ); @@ -555,6 +566,62 @@ public class CashierDepositRequestApiResource { // ── helpers ────────────────────────────────────────────────────────────────── + /** + * Records the two sides of an approved deposit on its voucher. + * + * A deposit is money arriving at the counter, so cash is debited and whatever receives it is + * credited — a member's savings account, or a GL account when the deposit was raised against + * one. The credited side is read from the request, because that is what the cashier actually + * filled in and what the member signed for. + * + * A failure here does not fail the approval. The deposit is already posted and the member's + * balance already raised; refusing that over its slip would take a correct transaction back for + * a paperwork reason. The problem is logged, and the voucher keeps its number without sides. + */ + private void addDepositVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + + try { + final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type")); + final String accountNo = (String) record.get("account_no"); + + // Cash reaching the counter. For a GL deposit the contra is the cash/vault account the + // request named; for a member deposit it is the cashier's own till, which the deposit + // register does not carry a GL id for — the line still names it, without an id. + final Object contraObj = record.get("contra_gl_id"); + final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null; + final Map contra = glAccountForVoucher(contraGlId); + final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"), + (String) contra.get("gl_code"), amount); + + final VoucherLine credit; + if (isGl) { + final Object glObj = record.get("gl_account_id"); + final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null; + final Map gl = glAccountForVoucher(glAccountId); + credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount); + } else { + credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount); + } + + voucherService.addLines(voucherNo, List.of(debit, credit)); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e); + } + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + private Map glAccountForVoucher(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, account_no, account_type, savings_account_id, @@ -748,7 +815,10 @@ public class CashierDepositRequestApiResource { .withJson(payload.toString()) .build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // The deposit register raises its own voucher, naming the member and their account; the + // journal command must not raise a second one over the same money. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getTransactionId() != null ? result.getTransactionId() : null; } 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 index 4db4f4227..8a9de15f2 100644 --- 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 @@ -206,7 +206,7 @@ public class ShareAdvanceCollectionApiResource { // ══════════════════════════════════════════════════════════════════════ // 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: + // The voucher arrives as a query parameter because numbers are formatted CR/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. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index d13f29caf..e7e9ddcff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.charge.domain.Charge; @@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService { private final FineractRequestContextHolder fineractRequestContextHolder; private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; private final SavingsHelper savingsHelper; + private final PlatformSecurityContext context; + private final VoucherService voucherService; + + /** A cheque book issuance charge, keyed by its m_cheque_book id. */ + private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE"; + /** The configured charge, if the admin has set one up — absent means "no charge configured yet." */ private FacilityChargeMapping resolveMapping() { @@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService { postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, accountId); + raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); } @@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService { this.currentAccountRepository.saveAndFlush(account); } + /** + * Records a cheque book issuance charge in the voucher register. + * + * The account is debited and the charge's income account credited — the society's fee, taken + * from the customer. The debit names the customer and their account number even though it posts + * against the product's control GL, so the slip reads as the customer's own. + * + * A failure here does not fail the charge. The money is already taken and the ledger already + * posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The + * problem is logged and the charge stands. + */ + private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId, + final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) { + try { + final Map holder = accountHolderForVoucher(accountType, accountId); + final Map incomeGl = this.voucherService.glAccount(incomeGlId); + final String narration = "Cheque book issuance charge - book #" + chequeBookId; + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId, + this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT, + List.of(VoucherLine + .debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount) + .withDescription(narration), + VoucherLine + .creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount) + .withDescription(narration))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e); + } + } + + /** The customer and account number a charge is taken from, for printing on a voucher. */ + private Map accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) { + final String sql = accountType == ChequeAccountType.SAVINGS + ? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa " + + "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?" + : "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca " + + "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?"; + return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); + } + /** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */ private Long getSavingsControlGlId(final Long savingsAccountId) { final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa " @@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService { final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The charge raises its own voucher, naming the account charged and the fee; the journal + // command must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); if (result == null) { log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId); return; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index ffae689a8..852f9124e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -24,6 +24,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; @@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -49,6 +53,11 @@ public class ClearingJournalService { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final FineractRequestContextHolder fineractRequestContextHolder; private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final VoucherService voucherService; + + /** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */ + private static final String REF_CLEARING_POSTING = "CLEARING_POSTING"; /** * Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null @@ -57,6 +66,19 @@ public class ClearingJournalService { */ public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) { + return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null); + } + + /** + * As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT. + * + * Savings and current accounts number independently, so the same id exists in both tables and the id + * alone cannot say whose account it is. A caller that knows the type passes it and the voucher names + * the right customer; one that has no account at all — a settlement summary — passes neither. + */ + public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, + final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId, + final String linkAccountType) { if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) { log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId, creditGlId, amount); @@ -86,7 +108,10 @@ public class ClearingJournalService { final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // Clearing raises its own voucher below, naming the customer account the item belongs to; + // the journal command must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); if (result == null) { log.warn("Clearing journal entry did not return a result ({}).", comments); return null; @@ -95,9 +120,77 @@ public class ClearingJournalService { if (glTxnId != null && linkAccountId != null) { linkDebitEntryToAccount(glTxnId, linkAccountId); } + raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; } + /** + * Records one clearing posting in the voucher register. + * + * Raised here rather than in each caller because CTS items, NACH movements and settlement + * summaries all post through this one path: a voucher per caller would be the same slip written + * three times, and one of them would drift. + * + * The caller's comment carries what the posting is — the item, the movement, the batch — so it + * becomes the narration on both sides. Where the caller named a customer account, the debit line + * carries that customer and account number; a settlement summary names no account and its lines + * stand on the GL names alone, which is what a summary is. + * + * A failure here does not fail the posting. The entry is already in the ledger, and clearing runs + * over batches where one unwritable slip must not take a whole settlement down with it. The + * problem is logged and the posting stands. + */ + private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount, + final String comments, final Long linkAccountId, final String linkAccountType) { + if (glTxnId == null) { + return; + } + try { + final Map debitGl = this.voucherService.glAccount(debitGlId); + final Map creditGl = this.voucherService.glAccount(creditGlId); + final Map holder = accountHolderForVoucher(linkAccountId, linkAccountType); + + // The debit is the side a customer account is named against, matching the row this + // posting links to the account for reporting. + final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name"); + final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no") + : (String) debitGl.get("gl_code"); + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId, + this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL, + List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments), + VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount) + .withDescription(comments))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e); + } + } + + /** + * The customer and account number a clearing item belongs to, for printing on a voucher. + * + * The type decides which table to read: savings and current accounts number independently, so + * the same id exists in both and looking in the wrong one would put another customer's name on + * the slip. Empty when the caller named no account, as a settlement summary does, or named one + * without saying which kind — a wrong name is worse on a voucher than no name. + */ + private Map accountHolderForVoucher(final Long accountId, final String accountType) { + if (accountId == null || accountType == null) { + return Map.of(); + } + final String sql; + if ("SAVINGS".equalsIgnoreCase(accountType)) { + sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa " + + "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"; + } else if ("CURRENT".equalsIgnoreCase(accountType)) { + sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca " + + "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?"; + } else { + return Map.of(); + } + return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); + } + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { final List rowIds = this.jdbcTemplate.queryForList( "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class, 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 b2ad10e9f..626f6e5c0 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 @@ -46,6 +46,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -65,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; @@ -719,6 +721,11 @@ public class ClerkDisbursementRequestApiResource { throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details."); } + // The sides of the slip, built from the entries as approved. Only reached once every + // posting above has succeeded, so the voucher describes a completed movement rather than + // one that was attempted. + addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType); + final String txnRefsStr = String.join(",", txnRefs); final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null); @@ -1004,7 +1011,7 @@ public class ClerkDisbursementRequestApiResource { /** * 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. + * the debited accounts, so it reads as a debit: DR/2108/0001. */ private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { return voucherService.createVoucher( @@ -1042,6 +1049,97 @@ public class ClerkDisbursementRequestApiResource { ); } + /** + * Records the sides of an approved disbursement, collection or bank transfer on its voucher. + * + * The register covers three directions under one request type, and each puts the society's GL + * on a different side: + * + *
    + *
  • DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each + * account credited.
  • + *
  • COLLECTION — money into the target GL out of the listed accounts: each account debited, + * GL credited.
  • + *
  • BANK_TRANSFER — account to account with no GL involved at all; each entry already carries + * its own side, so they are used as given.
  • + *
+ * + * The society's side is written as a single line for the total rather than one per counterparty, + * because that is how it posts and how the slip reads. + * + * A failure here does not fail the approval: every transaction is posted by this point, and the + * money has moved. The problem is logged, and the voucher keeps its number without sides. + */ + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, + final String requestType) { + + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + + try { + final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType); + final boolean isCollection = "COLLECTION".equals(requestType); + + final List lines = new ArrayList<>(); + BigDecimal counterpartyTotal = BigDecimal.ZERO; + + for (final JsonElement el : creditEntries) { + final JsonObject entry = el.getAsJsonObject(); + final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString()); + final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null; + final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull() + ? entry.get("accountName").getAsString() + : accountNo; + final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + + final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull() + ? entry.get("glAccountId").getAsLong() + : null; + final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull() + ? entry.get("savingsAccountId").getAsLong() + : null; + + // A bank transfer states each side on the entry itself; the other two directions + // put every counterparty on the side opposite the society's GL. + final boolean entryIsDebit = isBankTransfer + ? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") + : isCollection; + + final VoucherLine line; + if (isGlEntry) { + line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount) + : VoucherLine.creditGl(glId, accountName, accountNo, entryAmount); + } else { + line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount) + : VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount); + } + lines.add(line); + + if (!isBankTransfer) { + counterpartyTotal = counterpartyTotal.add(entryAmount); + } + } + + // The society's own side. A bank transfer has none — the money never leaves the members + // between them — so its entries already balance on their own. + if (!isBankTransfer) { + final String glName = (String) record.get("gl_account_name"); + final String glNo = (String) record.get("gl_account_no"); + lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal) + : VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal)); + } + + // Debits first, so the voucher prints the way a slip is read. + lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed()); + + voucherService.addLines(voucherNo, lines); + } catch (final RuntimeException e) { + log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e); + } + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount, @@ -1226,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource { .withJson(payload.toString()) .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - commandsSourceWritePlatformService.logCommandSource(cmd); + // The disbursement register raises its own voucher; suppress the journal command's own. + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); } // ── Combined GL entry helpers ───────────────────────────────────────────── @@ -1663,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource { .withJson(payload.toString()) .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); + // The disbursement register raises its own voucher, naming every account involved; the + // journal command must not raise a second one over the same money. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); return result != null ? result.getTransactionId() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index f490750f3..8a5985e8b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -69,6 +72,10 @@ import com.google.common.base.Splitter; @RequiredArgsConstructor public class ClerkWithdrawalRequestApiResource { + // Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the + // activity JsonArray, and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -457,6 +464,10 @@ public class ClerkWithdrawalRequestApiResource { VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId)); } + // The sides of the slip. A withdrawal is money leaving the member, so their account is + // debited and the till that pays it out is credited. + addWithdrawalVoucherLines(record, savingsAccountId, amount); + final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null ); @@ -697,6 +708,32 @@ public class ClerkWithdrawalRequestApiResource { } } + /** + * Records the two sides of an approved withdrawal on its voucher. + * + * Money leaves the member and is paid out over the counter, so the member's account is debited + * and the till is credited. The withdrawal register does not carry a GL id for the till — it + * pays out of whichever cashier's drawer serves the request — so that side is named without one. + * + * A failure here does not fail the approval: the withdrawal is already posted and the member + * already paid. The problem is logged, and the voucher keeps its number without sides. + */ + private void addWithdrawalVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) { + return; + } + + try { + voucherService.addLines(voucherNo, + List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"), + (String) record.get("account_no"), amount), + VoucherLine.creditGl(null, "Cash in Hand", null, amount))); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e); + } + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java index d54ba01c4..b6b0b58c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java @@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat controlGlId, suspenseGlId); } else { this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount, - "CTS inward clearing pass - item #" + itemId, item.getAccountId()); + "CTS inward clearing pass - item #" + itemId, item.getAccountId(), item.getAccountType()); } markLeaf(item, true, "CTS cleared", null, userId); 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 fc2bad879..eb00771ee 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 @@ -44,6 +44,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -51,6 +52,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -81,15 +84,20 @@ import org.springframework.transaction.annotation.Transactional; @Component @Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.") @RequiredArgsConstructor +@Slf4j public class DividendClaimRequestApiResource { private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; 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; + + /** A dividend payout, keyed by its m_share_dividend_claim_request id. */ + private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST"; /** 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; @@ -126,6 +134,9 @@ public class DividendClaimRequestApiResource { final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root)); final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root)); final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root)); + // Which physical leaflet the clerk took off the shelf. Optional: a caller that has not + // chosen one gets the oldest available instead. + final Long requestedLeafletId = fromJsonHelper.extractLongNamed("leafletId", root); if (lockId == null) { throw apiError(400, "lockId is required"); } @@ -153,15 +164,6 @@ public class DividendClaimRequestApiResource { throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft"); } - // 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( "SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId); if (existingPending != null && existingPending > 0) { @@ -170,19 +172,63 @@ public class DividendClaimRequestApiResource { validateNotGuarantorOfOverdueLoan(clientId); + // The leaflet is taken now rather than at approval, so the DD number exists from the moment + // the claim is raised and the draft has a place in the queue that nothing else can take. + // + // The clerk chooses which one on the leaflets screen, and that choice is honoured: the draft + // has to be drawn on the physical form in their hand, or the number written on the + // counterfoil is not the number in the books. Locked so two concurrent claims cannot take the + // same leaflet; if it was taken in the meantime the clerk is told rather than being given a + // different draft than the one they are holding. + final Map leaflet = requestedLeafletId != null + ? jdbcTemplate + .queryForList("SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ? AND status = 'AVAILABLE' " + + "FOR UPDATE SKIP LOCKED", requestedLeafletId) + .stream().findFirst() + .orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has " + + "just taken it. Go back and choose another leaflet.")) + // No leaflet named (a caller that is not the leaflets screen): fall back to the + // oldest available, which is the order a series is meant to be consumed in. + : 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. Add stock before claiming a dividend by DD.")); + final Long leafletId = ((Number) leaflet.get("id")).longValue(); + final String ddNumber = (String) leaflet.get("dd_number"); + final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); + // 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, 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', ?, ?, ?) + status, clerk_id, clerk_name, submitted_at, dd_number) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?) RETURNING id """, Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, - currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), ddNumber); - return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + // Raised as an ordinary PENDING issue request so the draft joins the DD Issuance Approvals + // queue with every other draft, rather than sitting in a second approvals screen of its own. + // payment_method 'DIVIDEND' is what tells that approval there is no purchaser to debit — + // the society already owes this money, so the draft is funded from Dividend Payable and the + // member pays nothing: no charge, no GST, which is why those columns are zero. + 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) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'PENDING', ?, ?) + RETURNING id + """, Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, + ddApplyAmount, branchId, java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), currentUser.getId(), + Timestamp.from(Instant.now())); + + jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET dd_issue_request_id = ? WHERE id = ?", issueRequestId, newId); + + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "ddNumber", ddNumber, "ddIssueRequestId", issueRequestId)); } /** @@ -471,13 +517,13 @@ public class DividendClaimRequestApiResource { // 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); + raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser); return; } @@ -495,6 +541,42 @@ public class DividendClaimRequestApiResource { // 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); + + // The voucher names the two accounts the payout actually moves between, not the three the + // ledger touches. Dividend Paid nets to nil across the pair above: it is there to measure + // how much has been claimed, and putting it on the slip would show the member a middle + // account that holds none of their money. + raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser); + } + + /** + * Records the payout in the voucher register: Dividend Payable debited, the DD liability credited. + * + * A failure here does not fail the claim. The dividend is already posted and the draft already + * raised, and refusing an approved payout because its slip could not be written would hold back + * a member's money over paperwork. The claim stands, and the register shows a gap. + */ + private void raiseDividendVoucher(final Long claimId, final Long debitGlId, final Long creditGlId, final BigDecimal amount, + final String description, final AppUser currentUser) { + try { + final Map debitGl = glAccountForVoucher(debitGlId); + final Map creditGl = glAccountForVoucher(creditGlId); + + voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser, + VoucherService.PREFIX_DEBIT, + List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount) + .withDescription(description), + VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount) + .withDescription(description))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for dividend claim {}", claimId, e); + } + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + private Map glAccountForVoucher(final Long glAccountId) { + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); } /** 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 43be84903..538799f5a 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 @@ -26,6 +26,7 @@ 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.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -127,6 +128,64 @@ public class DividendDashboardApiResource { return gson.toJson(result); } + // ── GET /v1/clerk/dividend-dashboard/declaration/{id}/report-header ────────── + + /** + * The heading of a Share Dividend Distribution Report: what rate the board resolved, what it came to, and when it + * was paid. + * + * The per-member breakdown screen already lists who gets what, but a distribution report is not just the list — it + * has to state the rate and the payment date, because that is what an AGM resolution and an auditor both ask for. + * Those two facts live on the declaration rather than on any member row, so they are fetched once here rather than + * repeated on every row of the member query. + * + * The rate is read as declared, never re-derived from amount over capital: a share movement back-dated into a + * closed period would silently change a rate the board actually resolved. + */ + @GET + @Path("/declaration/{dividendId}/report-header") + @Produces(MediaType.APPLICATION_JSON) + public String declarationReportHeader(@PathParam("dividendId") final Long dividendId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, p.name AS product_name, d.amount, d.rate_percentage, d.status, + d.dividend_period_start_date, d.dividend_period_end_date, + d.created_date, d.lastmodified_date, + COUNT(sad.id) FILTER (WHERE sad.amount > 0) AS paid_members, + COUNT(sad.id) FILTER (WHERE sad.amount = 0) AS unpaid_members, + COALESCE(SUM(sad.amount), 0) AS allocated_amount + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = d.id + WHERE d.id = ? + GROUP BY d.id, p.name + """, dividendId); + if (rows.isEmpty()) { + throw new NotFoundException("Dividend declaration not found: " + dividendId); + } + final Map row = rows.get(0); + final boolean approved = ((Number) row.get("status")).intValue() == DIVIDEND_PAYOUT_STATUS_APPROVED; + + final JsonObject out = new JsonObject(); + out.addProperty("dividendId", ((Number) row.get("id")).longValue()); + out.addProperty("productName", (String) row.get("product_name")); + final Object periodEnd = row.get("dividend_period_end_date"); + out.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + out.addProperty("periodEndDate", String.valueOf(periodEnd)); + out.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd))); + out.addProperty("ratePercentage", (BigDecimal) row.get("rate_percentage")); + out.addProperty("declaredAmount", (BigDecimal) row.get("amount")); + out.addProperty("allocatedAmount", (BigDecimal) row.get("allocated_amount")); + out.addProperty("paidMembers", ((Number) row.get("paid_members")).longValue()); + out.addProperty("unpaidMembers", ((Number) row.get("unpaid_members")).longValue()); + out.addProperty("status", approved ? "APPROVED" : "PENDING_APPROVAL"); + out.addProperty("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date"))); + // The payment date IS the approval date: approval is what makes a member's entitlement + // claimable, so nothing is payable before it and it is the date the report must quote. + out.addProperty("paymentDate", + approved && row.get("lastmodified_date") != null ? String.valueOf(row.get("lastmodified_date")) : null); + return gson.toJson(out); + } + // ── GET /v1/clerk/dividend-dashboard/declarations ──────────────────────────── /** @@ -176,6 +235,76 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/locks ─────────────────────────────────── + /** + * One member's dividend record across every financial year — what they were entitled to, what they collected, what + * they let lapse, and on which draft. + * + * The lock history answers "what is outstanding right now"; this answers "what has this member ever been paid", + * which is the question at the counter when someone asks about a dividend from years ago. Forfeited years are + * included deliberately: a member asking why they never received a dividend needs to be told it lapsed, not to be + * shown nothing. + * + * Searched by name or share account number, because a member at the counter has a passbook rather than a client id. + */ + @GET + @Path("/member-history") + @Produces(MediaType.APPLICATION_JSON) + public String memberHistory(@QueryParam("search") final String search) { + final String term = search == null ? "" : search.trim(); + if (term.length() < 2) { + return gson.toJson(new JsonArray()); + } + final String like = "%" + term.toLowerCase() + "%"; + + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id AS client_id, c.display_name AS client_name, sha.account_no AS share_account_no, + p.name AS product_name, d.dividend_period_start_date, d.dividend_period_end_date, + sad.amount AS entitled_amount, + l.id AS lock_id, l.status AS lock_status, l.amount AS lock_amount, + l.locked_date, l.release_due_date, ln.account_no AS applied_loan_account_no, + cr.status AS claim_status, cr.dd_number, cr.actioned_at + FROM m_share_account_dividend_details sad + JOIN m_share_account sha ON sha.id = sad.account_id + JOIN m_client c ON c.id = sha.client_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + JOIN m_share_product p ON p.id = d.product_id + LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id + LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id + LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') + WHERE sad.amount > 0 + AND (LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(sha.account_no, '')) LIKE ?) + ORDER BY c.display_name, d.dividend_period_end_date DESC + """, like, like); + + final JsonArray result = new JsonArray(); + for (final Map row : rows) { + final JsonObject entry = new JsonObject(); + entry.addProperty("clientId", ((Number) row.get("client_id")).longValue()); + entry.addProperty("clientName", (String) row.get("client_name")); + entry.addProperty("shareAccountNo", (String) row.get("share_account_no")); + entry.addProperty("productName", (String) row.get("product_name")); + final Object periodEnd = row.get("dividend_period_end_date"); + entry.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + entry.addProperty("periodEndDate", String.valueOf(periodEnd)); + entry.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd))); + entry.addProperty("entitledAmount", (BigDecimal) row.get("entitled_amount")); + final Object lockId = row.get("lock_id"); + entry.addProperty("lockId", lockId == null ? null : ((Number) lockId).longValue()); + // No lock row at all means the entitlement was below the payable minimum and was swept + // with the rounding residue — never anybody's money to collect. + entry.addProperty("status", lockId == null ? "BELOW_MINIMUM" : (String) row.get("lock_status")); + entry.addProperty("amount", (BigDecimal) row.get("lock_amount")); + entry.addProperty("lockedDate", row.get("locked_date") == null ? null : String.valueOf(row.get("locked_date"))); + entry.addProperty("releaseDueDate", row.get("release_due_date") == null ? null : String.valueOf(row.get("release_due_date"))); + entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no")); + entry.addProperty("claimStatus", (String) row.get("claim_status")); + entry.addProperty("ddNumber", (String) row.get("dd_number")); + entry.addProperty("claimedOn", row.get("actioned_at") == null ? null : String.valueOf(row.get("actioned_at"))); + result.add(entry); + } + return gson.toJson(result); + } + /** * 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 @@ -193,14 +322,26 @@ public class DividendDashboardApiResource { 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, " - + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " // + + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name, " // + // The share account the dividend was earned on. A clerk searching for a member at the + // counter has the passbook in front of them, which carries the account number and not + // the display name as the system spells it — so the number has to be searchable too. + + "sha.account_no AS share_account_no, " // + // The draft this dividend is being paid out as, and how far along it is. Once a claim + // exists the clerk needs the DD number rather than another Claim button — it is what + // goes on the counterfoil and what the member asks about when they come back. + + "cr.status AS claim_status, cr.dd_number AS claim_dd_number " // + "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_account sha ON sha.id = sad.account_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 " // + // The live claim only: a rejected one leaves the dividend claimable again, so it must + // not keep showing a DD number the member will never receive. + + "LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') " // + (filterByStatus ? "WHERE l.status = ? " : "") // // 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. @@ -214,6 +355,9 @@ public class DividendDashboardApiResource { entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue()); entry.addProperty("clientId", ((Number) row.get("client_id")).longValue()); entry.addProperty("clientName", (String) row.get("client_name")); + entry.addProperty("shareAccountNo", (String) row.get("share_account_no")); + entry.addProperty("claimStatus", (String) row.get("claim_status")); + entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number")); final Object savingsAccountId = row.get("savings_account_id"); entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue()); entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no")); @@ -247,9 +391,14 @@ public class DividendDashboardApiResource { public String list() { final List> locks = jdbcTemplate.queryForList(""" SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount, - l.locked_date, l.release_due_date + l.locked_date, l.release_due_date, + -- The share account the dividend was earned on, so a clerk holding the + -- member's passbook can find the row by its number rather than by name. + sha.account_no AS share_account_no FROM m_share_dividend_lock l JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + LEFT JOIN m_share_account sha ON sha.id = sad.account_id WHERE l.status = 'UNCLAIMED' AND EXISTS ( SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id @@ -266,6 +415,7 @@ public class DividendDashboardApiResource { row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue()); row.addProperty("clientId", clientId); row.addProperty("clientName", (String) lock.get("client_name")); + row.addProperty("shareAccountNo", (String) lock.get("share_account_no")); final Object savingsAccountId = lock.get("savings_account_id"); row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue()); row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 066de64b9..b180a5365 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -217,6 +217,8 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler; @@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional; public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService { private final PlatformSecurityContext context; + private final VoucherService voucherService; private final LoanTransactionValidator loanTransactionValidator; private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer; private final LoanRepositoryWrapper loanRepositoryWrapper; @@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); + // Money leaving the society to the borrower: the Loan Portfolio account is debited, because + // the loan is now an asset owed back, and the fund the money came out of is credited. + // Skipped for an account transfer, where the disbursement lands in another account rather + // than being paid out and that account's own posting carries the voucher. + if (!isAccountTransfer && disbursalTransactionId != null) { + final String voucherNo = raiseLoanVoucher(loan, disbursalTransactionId, loan.getDisbursedAmount(), true); + if (voucherNo != null) { + changes.put("voucherNo", voucherNo); + } + } + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(loan.getId()) // @@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf .build(); } + /** + * Records a loan disbursement or repayment in the voucher register, and returns its number. + * + * The borrower's side is the product's Loan Portfolio account, because that is what the balance + * posts against; the line still names the borrower and their loan account number, so the slip + * reads as theirs. The other side is the till. + * + * A disbursement debits the portfolio — the society now holds a receivable — and credits the + * cash paid out. A repayment reverses that. + * + * A failure here does not fail the transaction. The disbursement or repayment is already posted + * and the borrower's balance already moved; taking that back over a slip would undo a correct + * transaction for a paperwork reason. The problem is logged and the transaction stands. + */ + private String raiseLoanVoucher(final Loan loan, final Long transactionId, final BigDecimal amount, final boolean isDisbursement) { + // A register further out may have already raised a voucher naming the instrument this + // movement belongs to; its voucher describes it better than a bare loan posting would. + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final AppUser currentUser = this.context.authenticatedUser(); + final Map portfolio = this.voucherService.loanPortfolioAccount(loan.getId()); + final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue(); + + final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null; + final String accountNo = loan.getAccountNumber(); + final String partyName = party != null ? party : (String) portfolio.get("name"); + + final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount) + : VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount); + + // The till. The loan module does not resolve a GL for the cashier's drawer, so the line + // names it without an id rather than guessing at one. + final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount) + : VoucherLine.debitGl(null, "Cash in Hand", null, amount); + + final List lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide); + + return this.voucherService.createVoucherWithLines( + isDisbursement ? VoucherService.TYPE_DISBURSEMENT : VoucherService.TYPE_REPAYMENT, + VoucherService.REF_LOAN_TRANSACTION, transactionId, currentUser, + isDisbursement ? VoucherService.PREFIX_DEBIT : VoucherService.PREFIX_CREDIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for loan transaction {}", transactionId, e); + return null; + } + } + private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) { if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText); @@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf loan = loanTransaction.getLoan(); this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction); + // Money coming back from the borrower: cash is debited and the Loan Portfolio account is + // credited, reducing what the loan still carries as owed. + final String repaymentVoucherNo = raiseLoanVoucher(loan, loanTransaction.getId(), transactionAmount, false); + if (repaymentVoucherNo != null) { + changes.put("voucherNo", repaymentVoucherNo); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()) // .withLoanId(loan.getId()) // .withEntityId(loanTransaction.getId()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index d2c1ff73e..425a31928 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -176,6 +176,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.rate.service.RateAssembler; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; @@ -413,7 +414,7 @@ public class LoanAccountConfiguration { @Bean @ConditionalOnMissingBean(LoanWritePlatformService.class) - public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, + public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService, LoanTransactionValidator loanTransactionValidator, LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper, LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository, @@ -446,7 +447,8 @@ public class LoanAccountConfiguration { LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper, LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService, LoanTransactionService loanTransactionService) { - return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer, + return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator, + loanUpdateCommandFromApiJsonDeserializer, loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository, loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService, holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java index 561924545..e394eae20 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java @@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService { } if (txn.isCredit()) { this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId, - amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId()); + amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType()); } else { this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId, - amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId()); + amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java new file mode 100644 index 000000000..2fca89e25 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -0,0 +1,135 @@ +/** + * 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.savings.dailyinterest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Runs the day: accrue for every account, then pay out any product whose cycle ended today. + * + * Accrual and posting are separated on purpose. Accrual touches no money and can be repeated + * freely; posting moves money and must not be. Keeping them apart means a posting that fails can + * be retried without recomputing the accrual, and an accrual can be rebuilt after a backdated + * transaction without disturbing what has already been paid. + * + * Each product posts in its own transaction, so one product failing does not roll back another + * that already settled. The failure is recorded against that product's run and the day continues. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsDailyInterestOrchestrator { + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsPooledInterestPostingService postingService; + + /** Accrue for the day, then post for any product whose cycle ends today. */ + public void runForDate(final LocalDate businessDate) { + accrualService.accrueForDate(businessDate); + + final List> schedules = jdbcTemplate.queryForList(""" + SELECT s.savings_product_id, s.posting_period_enum + FROM m_savings_interest_posting_schedule s + JOIN m_savings_product p ON p.id = s.savings_product_id + WHERE s.is_active = true AND p.deposit_type_enum = 100 + ORDER BY s.savings_product_id + """); + + for (final Map schedule : schedules) { + final long productId = ((Number) schedule.get("savings_product_id")).longValue(); + final Long dueCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_daily_interest + WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true + AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0 + """, Long.class, productId, businessDate); + if (dueCount == null || dueCount == 0) { + updateSchedule(productId, businessDate, null); + continue; + } + + try { + final BigDecimal paid = postingService.postForProduct(productId, businessDate); + updateSchedule(productId, businessDate, "COMPLETED"); + log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); + } catch (final RuntimeException e) { + // The product rolled back on its own; record why and let the others run. + log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE savings_product_id = ? AND posting_date = ? + """, truncate(e.getMessage()), productId, businessDate); + updateSchedule(productId, businessDate, "FAILED"); + } + } + } + + /** + * Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger + * itself recorded, so the schedule and the rows can never drift apart. + */ + private void updateSchedule(final long productId, final LocalDate businessDate, final String status) { + final List> cycle = jdbcTemplate.queryForList(""" + SELECT MIN(cycle_end_date) AS next_end FROM m_savings_daily_interest + WHERE savings_product_id = ? AND balance_date = ? AND cycle_end_date >= ? + """, productId, businessDate, businessDate); + + LocalDate nextPosting = null; + if (!cycle.isEmpty() && cycle.get(0).get("next_end") != null) { + nextPosting = ((java.sql.Date) cycle.get(0).get("next_end")).toLocalDate(); + if (nextPosting.isEqual(businessDate) && "COMPLETED".equals(status)) { + final int period = jdbcTemplate.queryForObject( + "SELECT posting_period_enum FROM m_savings_interest_posting_schedule WHERE savings_product_id = ?", + Integer.class, productId); + nextPosting = SavingsInterestCycleCalculator.nextCycleEndDate(businessDate, period, + financialYearBeginningMonth()); + } + } + + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_schedule + SET next_posting_date = ?, + last_posting_date = CASE WHEN ? = 'COMPLETED' THEN ? ELSE last_posting_date END, + last_run_status = COALESCE(?, last_run_status), + last_run_at = CURRENT_TIMESTAMP, updated_on = CURRENT_TIMESTAMP + WHERE savings_product_id = ? + """, nextPosting, status, businessDate, status, productId); + } + + private int financialYearBeginningMonth() { + final Integer month = jdbcTemplate.queryForObject( + "SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class); + return month == null || month < 1 || month > 12 ? 1 : month; + } + + private static String truncate(final String message) { + if (message == null) { + return "unknown failure"; + } + return message.length() > 1000 ? message.substring(0, 1000) : message; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java new file mode 100644 index 000000000..2c00c0b66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -0,0 +1,445 @@ +/** + * 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.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Writes the day-by-day working behind each savings account's interest. + * + * The engine already computes interest correctly; what it does not do is leave a record. It keeps + * one derived total per account and recomputes it from the whole transaction history whenever + * anybody looks, so there is no way to ask what an account earned on a particular day, or why a + * day earned nothing. This service produces that record: one row per account per day, carrying the + * balance it was worked out on and the running total for the cycle. + * + * The interest terms - rate, posting period, compounding, calculation type, days in year - are read + * from the PRODUCT, deliberately. Fineract copies them onto each account at creation and the engine + * afterwards reads the account's copy, so the two can drift: in this tenant seventeen accounts on a + * quarterly 10% product still carry a daily 7% from whenever they were opened. Interest here is a + * property of the product a member bought, not of a stale copy, so this ledger states the product's + * terms and the whole product posts on one rhythm. The consequence is that these figures can differ + * from total_interest_earned_derived on any account whose copy has drifted; that difference is the + * point, and the reconciliation reports it rather than hiding it. + * + * The minimum balance is the exception: it is held only on the account, never on the product. + * + * Two rules the engine enforces and this has to match, or the ledger and the account will disagree: + * + * - Interest is taken on the day's CLOSING balance. EndOfDayBalance holds an opening balance too + * but never uses it in the arithmetic; opening is kept here only because it chains. + * - A day whose closing balance is under the account's minimum earns nothing at all, not a + * reduced amount. That is a cliff, and the row records it so a zero does not read as a fault. + * + * Scope is savings only - deposit_type_enum 100. Fixed and recurring deposits share + * m_savings_account and the same interest engine, but they carry maturity and lock-in rules this + * ledger does not model, so they are left alone. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsDailyInterestService { + + private static final MathContext MC = new MathContext(19, RoundingMode.HALF_EVEN); + private static final int SAVINGS_DEPOSIT_TYPE = 100; + private static final int ACTIVE_STATUS = 300; + + /** m_savings_account.interest_calculation_type_enum: 1 daily balance, 2 average daily balance. */ + private static final class SavingsInterestCalculationType { + + private SavingsInterestCalculationType() {} + + static final int AVERAGE_DAILY_BALANCE = 2; + } + + private final JdbcTemplate jdbcTemplate; + + /** + * Writes one day's rows for every active savings account, for both savings products. + * + * Safe to run more than once for the same day: rows are keyed on (account, date) and a repeat + * updates in place rather than accruing twice. That matters because a day rewritten after a + * backdated transaction has to be able to replace what is already there. + * + * @return how many rows were written + */ + @Transactional + public int accrueForDate(final LocalDate businessDate) { + final int financialYearBeginningMonth = financialYearBeginningMonth(); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sp.deposit_type_enum = ? + AND sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + AND sa.activatedon_date <= ? + ORDER BY sa.product_id, sa.id + """, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS, businessDate); + + int written = 0; + for (final Map account : accounts) { + written += accrueOneAccount(account, businessDate, financialYearBeginningMonth) ? 1 : 0; + } + log.info("Savings daily interest: wrote {} rows for {}", written, businessDate); + return written; + } + + private boolean accrueOneAccount(final Map account, final LocalDate businessDate, + final int financialYearBeginningMonth) { + final Long accountId = ((Number) account.get("id")).longValue(); + final Long productId = ((Number) account.get("product_id")).longValue(); + final LocalDate activatedOn = ((java.sql.Date) account.get("activatedon_date")).toLocalDate(); + final BigDecimal rate = (BigDecimal) account.get("rate"); + final int postingPeriod = ((Number) account.get("posting_period")).intValue(); + final Number compounding = (Number) account.get("compounding_period"); + final Number calcType = (Number) account.get("calculation_type"); + final int daysInYear = daysInYear(account.get("days_in_year"), businessDate); + final BigDecimal minBalance = (BigDecimal) account.get("min_balance"); + + // The previous row is the only thing that carries the chain forward. Its absence means + // this is the account's first day; a gap would mean a day the job never ran, and rather + // than silently re-anchoring we take the last row we do have and let the reconciliation + // report the break. + final List> previous = jdbcTemplate.queryForList(""" + SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, + cycle_start_date, cycle_end_date, is_posting_day + FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date < ? + ORDER BY balance_date DESC LIMIT 1 + """, accountId, businessDate); + + final BigDecimal openingBalance; + final BigDecimal carriedInterest; + final int cycleNumber; + final LocalDate cycleStart; + final LocalDate cycleEnd; + + if (previous.isEmpty()) { + openingBalance = BigDecimal.ZERO; + carriedInterest = BigDecimal.ZERO; + cycleNumber = 1; + // Cycle 1 is a stub: it starts when the account was activated, not on the grid. + cycleStart = activatedOn; + cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth); + } else { + final Map prev = previous.get(0); + openingBalance = (BigDecimal) prev.get("closing_balance"); + final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); + final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); + if (prevWasPosting) { + // The cycle closed yesterday: the sum went to the member and starts again at zero. + carriedInterest = BigDecimal.ZERO; + cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; + cycleStart = prevCycleEnd.plusDays(1); + cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod, + financialYearBeginningMonth); + } else { + carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); + cycleNumber = ((Number) prev.get("cycle_number")).intValue(); + cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); + cycleEnd = prevCycleEnd; + } + } + + final BigDecimal movement = dayMovement(accountId, businessDate); + final BigDecimal closingBalance = openingBalance.add(movement); + + // The minimum is a cliff, not a taper: below it nothing is earned. It is tested against + // whichever balance the account actually earns on, which is not always today's closing. + final boolean averageDailyBalance = calcType != null + && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE; + + final BigDecimal dailyInterest; + final BigDecimal cumulative; + final boolean belowMinimumForInterest; + + if (averageDailyBalance) { + // Average daily balance is a statement about the whole period, not about one day: + // the engine averages every closing balance in the cycle and applies the rate once + // across all of the days at once. So what this works out is the cycle's running + // total, and the day's own interest is whatever that total moved by since yesterday. + // Early in a cycle the figure is provisional - a later deposit shifts the average and + // restates it - which is inherent to the method, not an artefact of writing it down. + final int daysSoFar = (int) (businessDate.toEpochDay() - cycleStart.toEpochDay()) + 1; + final BigDecimal averageBalance = averageBalanceForCycle(accountId, cycleStart, businessDate, + closingBalance); + belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && averageBalance.compareTo(minBalance) < 0; + if (belowMinimumForInterest || averageBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null + || rate.compareTo(BigDecimal.ZERO) <= 0) { + cumulative = carriedInterest; + dailyInterest = BigDecimal.ZERO; + } else { + cumulative = averageBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC) + .multiply(BigDecimal.valueOf(daysSoFar), MC); + dailyInterest = cumulative.subtract(carriedInterest); + } + } else { + belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && closingBalance.compareTo(minBalance) < 0; + if (belowMinimumForInterest || closingBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null + || rate.compareTo(BigDecimal.ZERO) <= 0) { + dailyInterest = BigDecimal.ZERO; + } else { + dailyInterest = closingBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC); + } + cumulative = carriedInterest.add(dailyInterest); + } + final boolean isPostingDay = !businessDate.isBefore(cycleEnd); + + jdbcTemplate.update(""" + INSERT INTO m_savings_daily_interest + (savings_product_id, savings_account_id, balance_date, opening_balance, closing_balance, + daily_interest, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, + posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum, + posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET + opening_balance = EXCLUDED.opening_balance, + -- A day whose interest has already been paid keeps the balance it was left + -- with. Re-accruing that day recomputes the closing balance from + -- transactions alone, which does not include this ledger's own posting, and + -- writing it back would erase the credit from the chain: the next day would + -- open lower than the member's real balance and they would stop earning on + -- interest they have been given. + closing_balance = CASE + WHEN m_savings_daily_interest.posted_amount IS NOT NULL + THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount + ELSE EXCLUDED.closing_balance END, + daily_interest = EXCLUDED.daily_interest, + -- Likewise the total that was actually paid: once money has moved on the + -- strength of this figure, recomputing it would leave the ledger disagreeing + -- with the member's passbook. A posted day is settled. + cycle_cumulative_interest = CASE + WHEN m_savings_daily_interest.posted_amount IS NOT NULL + THEN m_savings_daily_interest.cycle_cumulative_interest + ELSE EXCLUDED.cycle_cumulative_interest END, + cycle_number = EXCLUDED.cycle_number, + cycle_start_date = EXCLUDED.cycle_start_date, + cycle_end_date = EXCLUDED.cycle_end_date, + posting_date = EXCLUDED.posting_date, + is_posting_day = EXCLUDED.is_posting_day, + below_min_balance = EXCLUDED.below_min_balance, + -- The terms are re-stamped too. A row rewritten under different product terms + -- must not keep the old rate beside a figure the new one produced, or the row + -- stops explaining itself. Days already posted keep their money above; this + -- only restates what the money was worked out from. + interest_rate = EXCLUDED.interest_rate, + days_in_year = EXCLUDED.days_in_year, + compounding_period_enum = EXCLUDED.compounding_period_enum, + posting_period_enum = EXCLUDED.posting_period_enum, + calculation_type_enum = EXCLUDED.calculation_type_enum, + min_balance_for_interest = EXCLUDED.min_balance_for_interest, + last_recomputed_on = CURRENT_TIMESTAMP + """, productId, accountId, businessDate, openingBalance, closingBalance, dailyInterest, cumulative, + cycleNumber, cycleStart, cycleEnd, cycleEnd, isPostingDay, rate, daysInYear, + compounding == null ? null : compounding.intValue(), postingPeriod, + calcType == null ? null : calcType.intValue(), minBalance, belowMinimumForInterest); + return true; + } + + /** + * The mean of the cycle's daily closing balances, today included. + * + * Rows already written for this cycle carry the earlier days; today's closing is not among + * them yet, so it is added by the caller. A day the ledger never wrote would pull the mean + * off, which is the other reason the chain check matters. + */ + private BigDecimal averageBalanceForCycle(final Long accountId, final LocalDate cycleStart, + final LocalDate businessDate, final BigDecimal todaysClosing) { + final BigDecimal earlierSum = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(closing_balance), 0) FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ? + """, BigDecimal.class, accountId, cycleStart, businessDate); + final Long earlierDays = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ? + """, Long.class, accountId, cycleStart, businessDate); + final long days = (earlierDays == null ? 0L : earlierDays) + 1L; + final BigDecimal total = (earlierSum == null ? BigDecimal.ZERO : earlierSum).add(todaysClosing); + return total.divide(BigDecimal.valueOf(days), MC); + } + + /** + * The day's net movement, excluding interest postings. + * + * Interest postings are left out on purpose. The posting is this ledger's own output, and + * folding it back in here would let it accrue interest on itself a second time. It is added to + * the closing balance by the posting run, on the posting day, once. + */ + private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { + final BigDecimal movement = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE + WHEN t.transaction_type_enum IN (1, 8) THEN t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + ELSE 0 END), 0) + FROM m_savings_account_transaction t + WHERE t.savings_account_id = ? + AND t.transaction_date = ? + AND t.is_reversed = false + """, BigDecimal.class, accountId, businessDate); + return movement == null ? BigDecimal.ZERO : movement; + } + + /** + * 365, 360, or the actual length of the year. The column holds the enum value, which for the + * two fixed options is the number itself; ACTUAL is 1 and means the calendar year. + */ + private int daysInYear(final Object raw, final LocalDate businessDate) { + if (raw == null) { + return 365; + } + final int value = ((Number) raw).intValue(); + if (value == 360 || value == 365) { + return value; + } + return businessDate.lengthOfYear(); + } + + private int financialYearBeginningMonth() { + final Integer month = jdbcTemplate.queryForObject( + "SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class); + return month == null || month < 1 || month > 12 ? 1 : month; + } + + /** + * Rebuilds the ledger for one account from a date forward, for when a backdated transaction + * changes what earlier days should have earned. + * + * Recomputing in place rather than writing correction rows: the ledger's job is to say what an + * account earned, and after a backdated deposit the honest answer is the new figure. The + * last_recomputed_on stamp is what records that the row was revisited. + */ + @Transactional + public int recomputeAccountFrom(final long accountId, final LocalDate fromDate, final LocalDate toDate) { + final int financialYearBeginningMonth = financialYearBeginningMonth(); + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, accountId); + if (rows.isEmpty()) { + return 0; + } + final Map account = rows.get(0); + int rewritten = 0; + for (LocalDate day = fromDate; !day.isAfter(toDate); day = day.plusDays(1)) { + accrueOneAccount(account, day, financialYearBeginningMonth); + rewritten++; + } + log.info("Savings daily interest: recomputed {} days for account {} from {}", rewritten, accountId, fromDate); + return rewritten; + } + + /** + * Backfills every active savings account from activation up to the given date. + * + * Existing accounts have months of history the ledger knows nothing about; without this the + * table starts blank and the first cycle it sees is a partial one that will not reconcile + * against the account's own totals. + */ + @Transactional + public int backfillAll(final LocalDate upToDate) { + final List accountIds = jdbcTemplate.queryForList(""" + SELECT sa.id FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sp.deposit_type_enum = ? AND sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + ORDER BY sa.id + """, Long.class, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS); + + int total = 0; + for (final Long accountId : accountIds) { + final java.sql.Date activated = jdbcTemplate.queryForObject( + "SELECT activatedon_date FROM m_savings_account WHERE id = ?", java.sql.Date.class, accountId); + if (activated == null) { + continue; + } + total += recomputeAccountFrom(accountId, activated.toLocalDate(), upToDate); + } + log.info("Savings daily interest: backfilled {} rows across {} accounts", total, accountIds.size()); + return total; + } + + /** + * The two checks worth running: that no day's opening lost touch with the day before it, and + * that the open cycle still agrees with what the account itself reports as unposted. + */ + public List reconcile() { + final List problems = new ArrayList<>(); + + jdbcTemplate.queryForList(""" + SELECT curr.savings_account_id, curr.balance_date, curr.opening_balance, prev.closing_balance + FROM m_savings_daily_interest curr + JOIN m_savings_daily_interest prev + ON prev.savings_account_id = curr.savings_account_id + AND prev.balance_date = curr.balance_date - INTERVAL '1 day' + WHERE curr.opening_balance <> prev.closing_balance + ORDER BY curr.savings_account_id, curr.balance_date + """).forEach(row -> problems.add(String.format( + "chain break: account %s on %s opens at %s but previous day closed at %s", + row.get("savings_account_id"), row.get("balance_date"), row.get("opening_balance"), + row.get("closing_balance")))); + + jdbcTemplate.queryForList(""" + SELECT d.savings_account_id, d.cycle_cumulative_interest AS ledger, + COALESCE(sa.total_interest_earned_derived, 0) - COALESCE(sa.total_interest_posted_derived, 0) AS account + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + WHERE d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_account_id = d.savings_account_id) + AND ABS(d.cycle_cumulative_interest + - (COALESCE(sa.total_interest_earned_derived, 0) + - COALESCE(sa.total_interest_posted_derived, 0))) > 0.01 + """).forEach(row -> problems.add(String.format( + "unposted mismatch: account %s ledger says %s, account says %s", row.get("savings_account_id"), + row.get("ledger"), row.get("account")))); + + return problems; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java new file mode 100644 index 000000000..61a19c305 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java @@ -0,0 +1,146 @@ +/** + * 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.savings.dailyinterest; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; + +/** + * Where a posting cycle ends, for a given posting period. + * + * This mirrors SavingsHelper.determineInterestPostingPeriodEndDateFrom, which is private to the + * engine and works on the engine's own period objects. The daily ledger needs the same boundaries + * to label its rows, so the rule is restated here rather than reached into. + * + * The boundaries are calendar dates anchored on the financial year start, not anniversaries of the + * account: an account opened on 24 August under a quarterly product does not post on 24 November, + * it posts at the next quarter end on the grid. The consequence is that the first cycle of any + * account is a stub - activation to the first boundary - and every cycle after it is a full period. + * + * One deliberate difference from the engine. Fineract adds a day to the period end and posts on the + * morning after ("interest posting always occurs on next day after the period end date"), unless + * the savings-interest-posting-current-period-end configuration is on, in which case it posts on + * the period end itself. This ledger always uses the period end date, matching that configuration + * being enabled, which is the behaviour this feature was asked for. If the configuration is left + * off, the engine's own posting transaction will be dated one day later than the ledger's + * posting_date, and the reconciliation will show it - the amounts still agree, only the date moves. + */ +public final class SavingsInterestCycleCalculator { + + private SavingsInterestCycleCalculator() {} + + // m_savings_product.interest_posting_period_enum / SavingsPostingInterestPeriodType + public static final int DAILY = 1; + public static final int MONTHLY = 4; + public static final int QUARTERLY = 5; + public static final int BIANNUAL = 6; + public static final int ANNUAL = 7; + + /** + * The last day of the cycle that {@code within} falls in - the day interest is posted. + * + * @param within + * any day inside the cycle + * @param postingPeriodEnum + * the account's posting period + * @param financialYearBeginningMonth + * 1-12, from the financial-year-beginning-month configuration + */ + public static LocalDate cycleEndDate(final LocalDate within, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + switch (postingPeriodEnum) { + case DAILY: + return within; + case MONTHLY: + return within.with(TemporalAdjusters.lastDayOfMonth()); + case QUARTERLY: + return periodEndOnGrid(within, financialYearBeginningMonth, 3); + case BIANNUAL: + return periodEndOnGrid(within, financialYearBeginningMonth, 6); + case ANNUAL: + return periodEndOnGrid(within, financialYearBeginningMonth, 12); + default: + throw new IllegalArgumentException("Unsupported savings interest posting period: " + postingPeriodEnum); + } + } + + /** + * The first day of the cycle that {@code within} falls in, ignoring activation. Callers hold + * the account's start date and take the later of the two, which is what makes cycle 1 a stub. + */ + public static LocalDate cycleStartDate(final LocalDate within, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + if (postingPeriodEnum == DAILY) { + return within; + } + final LocalDate previousEnd = cycleEndDate(within, postingPeriodEnum, financialYearBeginningMonth); + // The cycle containing `within` starts the day after the previous cycle ended. + return cycleEndDate(previousEnd.minusMonths(monthsIn(postingPeriodEnum)), postingPeriodEnum, + financialYearBeginningMonth).plusDays(1); + } + + /** The cycle after the one ending on {@code cycleEnd}. */ + public static LocalDate nextCycleEndDate(final LocalDate cycleEnd, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + return cycleEndDate(cycleEnd.plusDays(1), postingPeriodEnum, financialYearBeginningMonth); + } + + private static int monthsIn(final int postingPeriodEnum) { + switch (postingPeriodEnum) { + case MONTHLY: + return 1; + case QUARTERLY: + return 3; + case BIANNUAL: + return 6; + case ANNUAL: + return 12; + default: + return 0; + } + } + + /** + * Walks the fixed grid of period ends starting at the financial year month and returns the + * first one on or after {@code within}. + * + * The grid is built from the financial year start rather than from January, so a year + * beginning in April gives quarter ends of 30 Jun, 30 Sep, 31 Dec, 31 Mar - the same dates + * the engine produces for the same configuration. + */ + private static LocalDate periodEndOnGrid(final LocalDate within, final int financialYearBeginningMonth, + final int monthsPerPeriod) { + // Last day of the month before the financial year starts, in the year `within` falls in. + LocalDate gridAnchor = within.withDayOfMonth(1).withMonth(financialYearBeginningMonth).minusDays(1); + if (gridAnchor.isAfter(within)) { + gridAnchor = gridAnchor.minusYears(1); + } + LocalDate candidate = gridAnchor; + // At most 12/monthsPerPeriod steps land on or past `within`; the extra step guards the + // case where the anchor itself is the answer. + for (int i = 0; i <= 12 / monthsPerPeriod; i++) { + if (!candidate.isBefore(within)) { + return candidate; + } + candidate = candidate.plusDays(1).plusMonths(monthsPerPeriod).minusDays(1) + .with(TemporalAdjusters.lastDayOfMonth()); + } + return candidate; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java new file mode 100644 index 000000000..4c2ca6b81 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -0,0 +1,300 @@ +/** + * 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.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pays a savings product's accrued interest to its members, pooled through two control accounts. + * + * Stock Fineract posts interest one member at a time - debit Interest on Savings, credit Savings + * Control, per account - and its posting job spreads that work across a thread pool, each batch in + * its own transaction. That is sound double entry, but it has no seam a failure can be caught on: + * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere + * holds the figure that would say so. + * + * This posts the product's total first, into Savings Interest Posting Control, moves it to Savings + * Interest Posted Control, and only then pays it out to members. The whole product settles inside + * one transaction, and both control accounts have to read zero before it is allowed to commit. A + * failure rolls the product back entirely - nobody is half paid - and a run that never reached + * commit leaves a PENDING row naming the product and date for the next run to pick up. + * + * The paise problem is the one worth stating plainly. Each member's interest is rounded to two + * decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool + * ends a few paise adrift and that money is simply gone. Here the residual is worked out and given + * to the largest holding - the account least distorted by it - so the pool closes exactly and the + * amount that moved is written down in the run record. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsPooledInterestPostingService { + + /** Two decimals: what the member is actually credited. */ + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + + private static final String GL_POSTING_CONTROL = "200304"; + private static final String GL_POSTED_CONTROL = "200305"; + + /** m_savings_account_transaction.transaction_type_enum */ + private static final int INTEREST_POSTING = 3; + /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ + private static final int ENTITY_TYPE_SAVINGS = 2; + private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3; + + private final JdbcTemplate jdbcTemplate; + + /** + * Posts one product's interest for one posting date. + * + * REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other + * product's completed run down with it. + * + * @return the amount distributed, or zero if there was nothing to do + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { + final Long completed = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_interest_posting_run + WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED' + """, Long.class, productId, postingDate); + if (completed != null && completed > 0) { + log.info("Savings interest posting: product {} already completed for {}", productId, postingDate); + return BigDecimal.ZERO; + } + + // The accounts due today, with what they accrued. Anything already carrying a posting + // transaction is skipped, so a resumed run cannot pay somebody a second time. + // A savings account has no office of its own: it belongs to a client, or to a group when + // there is no client, and the office is theirs. The journal entries have to be raised + // against that office or they land in the wrong branch's books. + final List> due = jdbcTemplate.queryForList(""" + SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, + d.cycle_cumulative_interest AS gross_interest, + COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code, + sa.currency_digits + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND COALESCE(c.office_id, g.office_id) IS NOT NULL + ORDER BY d.savings_account_id + """, productId, postingDate); + + if (due.isEmpty()) { + log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate); + return BigDecimal.ZERO; + } + + final long runId = openRun(productId, postingDate, due.size()); + + // Round each member's share, then make the rounded shares add up. The residual is the + // difference between what the pool holds and what the members would otherwise receive. + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal gross = (BigDecimal) row.get("gross_interest"); + final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); + row.put("rounded_interest", rounded); + grossTotal = grossTotal.add(gross); + roundedTotal = roundedTotal.add(rounded); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + + if (residual.compareTo(BigDecimal.ZERO) != 0) { + // To the largest balance: the account where a paisa is proportionally smallest. + final Map largest = due.stream() + .max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow(); + largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); + log.info("Savings interest posting: residual {} assigned to account {}", residual, + largest.get("savings_account_id")); + } + + final Long postingControlGl = glAccountId(GL_POSTING_CONTROL); + final Long postedControlGl = glAccountId(GL_POSTED_CONTROL); + final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); + final String currency = (String) due.get(0).get("currency_code"); + final String poolReference = "SIP-" + productId + "-" + postingDate; + + // Stage 1: the product's whole interest bill, recognised and pooled. + journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); + + // Stage 2: the pool moves to the account it is paid out of. + journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + + // Stage 3: out to the members, one at a time. + BigDecimal distributed = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + final long accountId = ((Number) row.get("savings_account_id")).longValue(); + final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount); + // The member's own branch, which need not be the branch the pool was raised in. + final long accountOfficeId = ((Number) row.get("office_id")).longValue(); + + final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, + newBalance); + + journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, + accountId); + creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, + accountId, transactionId); + + jdbcTemplate.update(""" + UPDATE m_savings_daily_interest + SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, + closing_balance = closing_balance + ? + WHERE id = ? + """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); + + jdbcTemplate.update(""" + UPDATE m_savings_account + SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ? + WHERE id = ? + """, amount, amount, accountId); + + distributed = distributed.add(amount); + } + + // The whole point of the two control accounts: if this does not hold, nothing commits. + if (pooled.compareTo(distributed) != 0) { + throw new IllegalStateException(String.format( + "Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s", + productId, postingDate, pooled, distributed)); + } + + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?, + status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, pooled, distributed, residual, runId); + + log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId, + postingDate, distributed, due.size(), residual); + return distributed; + } + + private long openRun(final long productId, final LocalDate postingDate, final int accountCount) { + jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_run + (savings_product_id, posting_date, account_count, status) + VALUES (?, ?, ?, 'PENDING') + ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET + account_count = EXCLUDED.account_count, status = 'PENDING', + started_at = CURRENT_TIMESTAMP, failure_reason = NULL + """, productId, postingDate, accountCount); + return jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_interest_posting_run + WHERE savings_product_id = ? AND posting_date = ? + """, Long.class, productId, postingDate); + } + + private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date, + final BigDecimal amount, final BigDecimal runningBalance) { + jdbcTemplate.update(""" + INSERT INTO m_savings_account_transaction + (savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction, + transaction_type_enum, transaction_date, amount, running_balance_derived, + submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc) + VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, accountId, officeId, INTEREST_POSTING, date, amount, runningBalance, date, systemUserId(), + systemUserId()); + return jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ? + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, date, INTEREST_POSTING); + } + + /** The member's own leg: the product's Savings Control account, credited per account. */ + private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, + final LocalDate date, final BigDecimal amount, final String reference, final long accountId, + final long transactionId) { + final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL + journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); + } + + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) { + journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); + } + + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, + final Long transactionId) { + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + savings_transaction_id, reversed, manual_entry, office_running_balance, + organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date, + created_on_utc, last_modified_on_utc, submitted_on_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount, + debit ? "Savings interest posting" : "Savings interest posted", + savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, + systemUserId(), systemUserId(), date); + } + + /** + * The user these rows are attributed to. The job runs without anybody logged in, and the audit + * columns are NOT NULL, so entries are stamped to the system account the scheduler uses. + */ + private Long systemUserId() { + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class); + return ids.isEmpty() ? 1L : ids.get(0); + } + + private Long glAccountId(final String glCode) { + return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode); + } + + private Long mappedGlAccount(final long productId, final int financialAccountType) { + return jdbcTemplate.queryForObject(""" + SELECT gl_account_id FROM acc_product_mapping + WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? + LIMIT 1 + """, Long.class, productId, financialAccountType); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java new file mode 100644 index 000000000..82b77585a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -0,0 +1,178 @@ +/** + * 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.savings.dailyinterest.api; + +import com.google.gson.Gson; +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 java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Reads the savings daily interest ledger, per product and per account. + * + * The per-product listing is the view this was built for: pick a product, see its accounts and + * where each one is in its cycle. The per-account listing is the working itself - a day per row, + * the balance it was taken on, and the running total resetting to zero the day interest is paid. + */ +@Path("/v1/savingsdailyinterest") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsDailyInterestApiResource { + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsDailyInterestOrchestrator orchestrator; + private final Gson gson = new Gson(); + + /** The savings products carrying a ledger, with where each one is in its timetable. */ + @GET + @Path("/products") + public String products() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT p.id, p.name, p.short_name, p.nominal_annual_interest_rate AS rate, + s.posting_period_enum, s.run_at_time, s.last_posting_date, s.next_posting_date, + s.last_run_status, s.is_active, + (SELECT COUNT(*) FROM m_savings_account sa + WHERE sa.product_id = p.id AND sa.status_enum = 300) AS active_accounts, + (SELECT COALESCE(SUM(d.cycle_cumulative_interest), 0) + FROM m_savings_daily_interest d + WHERE d.savings_product_id = p.id + AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_product_id = p.id)) AS accrued_this_cycle + FROM m_savings_product p + LEFT JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id + WHERE p.deposit_type_enum = 100 + ORDER BY p.id + """); + return gson.toJson(rows); + } + + /** One product's accounts as at the latest day the ledger holds. */ + @GET + @Path("/products/{productId}/accounts") + public String accountsForProduct(@PathParam("productId") final Long productId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.savings_account_id, sa.account_no, c.display_name AS client_name, + d.balance_date, d.opening_balance, d.closing_balance, d.daily_interest, + d.cycle_cumulative_interest, d.cycle_number, d.cycle_start_date, d.cycle_end_date, + d.below_min_balance + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + WHERE d.savings_product_id = ? + AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_product_id = ?) + ORDER BY sa.account_no + """, productId, productId); + return gson.toJson(rows); + } + + /** One account's day-by-day working, newest first. */ + @GET + @Path("/accounts/{accountId}") + public String accountLedger(@PathParam("accountId") final Long accountId, + @QueryParam("limit") final Integer limit) { + final int rowLimit = limit == null || limit <= 0 ? 200 : Math.min(limit, 2000); + final List> rows = jdbcTemplate.queryForList(""" + SELECT balance_date, opening_balance, closing_balance, daily_interest, + cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, + is_posting_day, posted_amount, savings_transaction_id, below_min_balance, + interest_rate, days_in_year, min_balance_for_interest, last_recomputed_on + FROM m_savings_daily_interest + WHERE savings_account_id = ? + ORDER BY balance_date DESC + LIMIT ? + """, accountId, rowLimit); + return gson.toJson(rows); + } + + /** What each posting run pooled and paid, and whether it finished. */ + @GET + @Path("/runs") + public String runs(@QueryParam("productId") final Long productId) { + final String sql = """ + SELECT r.id, r.savings_product_id, p.name AS product_name, r.posting_date, r.account_count, + r.pooled_amount, r.distributed_amount, r.rounding_residual, r.status, + r.failure_reason, r.started_at, r.completed_at + FROM m_savings_interest_posting_run r + JOIN m_savings_product p ON p.id = r.savings_product_id + """ + (productId == null ? "" : " WHERE r.savings_product_id = ? ") + + " ORDER BY r.posting_date DESC, r.savings_product_id"; + final List> rows = productId == null ? jdbcTemplate.queryForList(sql) + : jdbcTemplate.queryForList(sql, productId); + return gson.toJson(rows); + } + + /** + * The chain check and the unposted-total check. An empty list is the answer you want: every + * day picks up where the last one left off, and the ledger still agrees with the accounts. + */ + @GET + @Path("/reconcile") + public String reconcile() { + final List problems = accrualService.reconcile(); + final Map result = new LinkedHashMap<>(); + result.put("clean", problems.isEmpty()); + result.put("problemCount", problems.size()); + result.put("problems", problems); + return gson.toJson(result); + } + + /** Runs a day by hand, for a first backfill or after fixing a failed night. */ + @POST + @Path("/run") + public String run(@QueryParam("date") final String dateParam) { + final LocalDate businessDate = dateParam == null || dateParam.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(dateParam); + orchestrator.runForDate(businessDate); + final Map result = new LinkedHashMap<>(); + result.put("ranFor", businessDate.toString()); + return gson.toJson(result); + } + + /** Rebuilds the ledger for every account from activation to the given date. */ + @POST + @Path("/backfill") + public String backfill(@QueryParam("upTo") final String upToParam) { + final LocalDate upTo = upToParam == null || upToParam.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(upToParam); + final int rows = accrualService.backfillAll(upTo); + final Map result = new LinkedHashMap<>(); + result.put("rowsWritten", rows); + result.put("upTo", upTo.toString()); + return gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 5ea7fe588..212c9732c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -97,6 +97,8 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; @@ -139,6 +141,8 @@ import org.springframework.util.CollectionUtils; public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService { private final PlatformSecurityContext context; + private final VoucherService voucherService; + private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; private final SavingsAccountDataValidator fromApiJsonDeserializer; private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper; private final StaffRepositoryWrapper staffRepository; @@ -344,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } + // Money arriving at the counter: cash is debited and the member's savings control account + // is credited. + final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true); + if (depositVoucherNo != null) { + changes.put("voucherNo", depositVoucherNo); + } + return new CommandProcessingResultBuilder() // .withEntityId(deposit.getId()) // .withOfficeId(account.officeId()) // @@ -420,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } + // Money leaving the member over the counter: their savings control account is debited and + // the till that pays it out is credited. + final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false); + if (withdrawalVoucherNo != null) { + changes.put("voucherNo", withdrawalVoucherNo); + } + return new CommandProcessingResultBuilder() // .withEntityId(withdrawal.getId()) // .withOfficeId(account.officeId()) // @@ -584,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.savingAccountRepositoryWrapper.saveAndFlush(account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); + + // Interest credited to the member, recorded against the transactions this run created. + // existingTransactionIds is what the account held before posting, so anything not in it + // is new — which is how a re-run over an account that already had interest posted last + // quarter raises a voucher for this quarter's credit only. + raiseInterestVouchers(account, existingTransactionIds, backdatedTxnsAllowedTill); } } @@ -2008,4 +2032,79 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory"); } } -} + + private void raiseInterestVouchers(final SavingsAccount account, final Set existingTransactionIds, + final boolean backdatedTxnsAllowedTill) { + if (!this.voucherService.shouldRaiseVoucher()) { + return; + } + + try { + final List transactions = backdatedTxnsAllowedTill + ? account.getSavingsAccountTransactionsWithPivotConfig() + : account.getTransactions(); + + final Map control = this.voucherService.savingsControlAccount(account.getId()); + final Map expense = this.voucherService.savingsInterestExpenseAccount(account.getId()); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue(); + + final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; + final String partyName = party != null ? party : (String) control.get("name"); + final String accountNo = account.getAccountNumber(); + final AppUser currentUser = this.context.authenticatedUser(); + + for (final SavingsAccountTransaction transaction : transactions) { + if (transaction.getId() == null || existingTransactionIds.contains(transaction.getId()) + || !transaction.isInterestPostingAndNotReversed()) { + continue; + } + final BigDecimal amount = transaction.getAmount(); + if (amount == null || amount.signum() <= 0) { + continue; + } + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, + transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT, + List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount) + .withDescription("Interest posted to " + accountNo), + VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) + .withDescription("Interest credited"))); + } + } catch (final RuntimeException e) { + log.error("Could not raise interest vouchers for savings account {}", account.getId(), e); + } + } + + private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount, + final boolean isDeposit) { + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final AppUser currentUser = this.context.authenticatedUser(); + final Map control = this.voucherService.savingsControlAccount(account.getId()); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + + final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; + final String accountNo = account.getAccountNumber(); + + final VoucherLine memberSide = isDeposit + ? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount) + : VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount); + + final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount) + : VoucherLine.creditGl(null, "Cash in Hand", null, amount); + + final List lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide); + + return this.voucherService.createVoucherWithLines( + isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId, + currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for savings transaction {}", transactionId, e); + return null; + } + } +} \ No newline at end of file diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 722d25070..d732e933a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -69,6 +69,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator; import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator; @@ -463,9 +464,10 @@ public class SavingsConfiguration { EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, - ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) { - return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, - staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, + ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) { + return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer, + savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, + savingsAccountTransactionDataValidator, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService, savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, 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 30d621458..b735ef91a 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 @@ -41,7 +41,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400; + /** Retained profit, where a dividend nobody claimed returns to. */ + private static final String PROFIT_GL_CODE = "BANKPROFIT01"; // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; @@ -117,13 +118,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe * *
      *   Dr  Dividend Payable            — the obligation ends; it was never going to be paid
-     *       Cr  Dividend Forfeited Income
+     *       Cr  Bank Profit
      * 
* - * 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. + * Credited straight to Bank Profit rather than routed through an income account: the money never left the society, + * so this is a liability that turned out not to be owed, and it returns directly to the retained profit the + * dividend was declared out of. * * 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 @@ -134,15 +134,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe .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) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) { + final Long bankProfitAccountId = this.jdbcTemplate + .queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst() + .orElse(null); + if (payableDividendsAccountId == null || bankProfitAccountId == null) { return; } @@ -164,7 +159,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe // 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, + this.jdbcTemplate.update(insertSql, bankProfitAccountId, 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/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 6f84b86ec..7d6b5e5f8 100644 --- 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 @@ -59,9 +59,13 @@ import org.springframework.stereotype.Component; * (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. + * The declarations are created through the existing per-product command, so the whole downstream chain — rounding, the + * residue sweep, the funding check against available profit — behaves exactly as it does for a single-product + * declaration. + * + * The rate is applied to share capital and to nothing else. Profit does not size the dividend — it only funds it: the + * amount that falls out of rate x capital is drawn from the Bank Profit (Non-Distributable) GL, and the only thing + * profit decides is whether there is enough there to pay it. */ @Path("/v1/shareproducts/dividends") @Component @@ -75,13 +79,15 @@ public class AllProductsDividendApiResource { /** 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. */ + /** + * A dividend is a rate on each member's own paid-up capital, and nothing else. + * + * The amount is derived entirely from what members invested and for how long — profit does not size it. Profit is + * only the source the payout is funded from, checked separately so the society cannot commit money it does not + * hold. Kept as a constant because the stored declarations still carry a basis field. + */ 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; @@ -137,8 +143,7 @@ public class AllProductsDividendApiResource { @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) { + @QueryParam("ratePercentage") final BigDecimal ratePercentage) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { @@ -149,26 +154,18 @@ public class AllProductsDividendApiResource { 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); + // Not what sizes the dividend — only what funds it. Carried so the screen can warn before the + // officer commits, rather than letting the command layer reject a declaration after the fact. 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 BigDecimal amount = amountForProduct(ratePercentage, capital.weightedCapital); final Map entry = new LinkedHashMap<>(); entry.put("productId", capital.productId); @@ -187,13 +184,16 @@ public class AllProductsDividendApiResource { out.put("fromDate", fromDate); out.put("toDate", toDate); out.put("financialYear", financialYearOf(toDate)); - out.put("dividendBasis", basis); + out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); 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. + // The funding position, not the basis. Carried so a rate the profit account cannot cover is + // visible on screen before submission — the command layer enforces the same ceiling, and an + // officer should not discover it only when the declaration is rejected. out.putAll(waterfall.asMap()); + out.put("fundingAvailable", waterfall.distributableProfit); + out.put("fundingShortfall", grandTotal.subtract(waterfall.distributableProfit).max(BigDecimal.ZERO)); // 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)); @@ -225,7 +225,6 @@ public class AllProductsDividendApiResource { } 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) { @@ -237,10 +236,6 @@ public class AllProductsDividendApiResource { } 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<>(); @@ -260,7 +255,7 @@ public class AllProductsDividendApiResource { continue; } - final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool); + final BigDecimal amount = amountForProduct(rate, capital.weightedCapital); if (amount.signum() <= 0) { skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product.")); continue; @@ -281,6 +276,15 @@ public class AllProductsDividendApiResource { .withJson(payload.toString()).build(); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); + // The rate the board actually resolved, recorded as declared. Written here rather + // than carried through the command because the per-product command takes an amount, + // not a rate — and re-deriving the rate later (amount / weighted capital) drifts the + // moment any share movement lands in a closed period. The Dividend Distribution + // Report and the AGM minutes both quote this figure, so it has to be the one + // resolved, not one recomputed. + jdbcTemplate.update("UPDATE m_share_product_dividend_pay_out SET rate_percentage = ? WHERE id = ?", rate, + result.getSubResourceId()); + final Map row = new LinkedHashMap<>(); row.put("productId", productId); row.put("productName", productName); @@ -341,27 +345,16 @@ public class AllProductsDividendApiResource { } /** - * What one product's declaration comes to, under whichever basis the officer chose. + * What one product's declaration comes to: the declared rate on the capital its members hold. * - * 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. + * This is what a rate means in an AGM resolution — every member receives that rate on their own paid-up capital, + * pro-rated for how long they held it. The society's profit plays no part in the arithmetic; it only has to be + * large enough to fund the result, which is checked separately at the command layer. */ - private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital, - final BigDecimal totalWeightedCapital, final BigDecimal profitPool) { + private BigDecimal amountForProduct(final BigDecimal ratePercentage, final BigDecimal weightedCapital) { 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); } @@ -369,78 +362,47 @@ public class AllProductsDividendApiResource { 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. + * The profit available to fund a dividend for a period. * - * 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. + * Just the one figure, because that is all profit decides now: the dividend amount comes from members' share + * capital, and profit only has to be able to pay it. The statutory reserve and education fund are not deducted + * here — they are appropriated on the profit-distribution screen before a dividend is declared, so the balance + * this reads has already had them taken out. */ 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) { + private ProfitWaterfall(final BigDecimal netProfit, 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. + * What the profit account holds for the period, which is what a dividend can be funded from. * * 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. + * guessed at from a P&L that is still moving. A loss-making year funds nothing, so it collapses to zero rather + * than a negative figure that would read as money owed back to the society. */ 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); + return new ProfitWaterfall(netProfit == null ? zero : netProfit, 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)); + return new ProfitWaterfall(netProfit, netProfit); } /** @@ -489,30 +451,27 @@ public class AllProductsDividendApiResource { 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. + * + * The {@code + 1} matches the assembler's: the period end is itself a day the shares were held, so a holding is + * measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the + * obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing. */ 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) + SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 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, ?)) >= ? + AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? """, 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java new file mode 100644 index 000000000..5da49befc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java @@ -0,0 +1,260 @@ +/** + * 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.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +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; + +/** + * Return on Members & Share Capital — the membership and share-capital position a co-operative bank reports to the + * RBI, per share type, with the previous year alongside for comparison. + * + * A supervisor reading this is asking three things of every share type the society offers: how many members hold it, + * how much capital they have put in, and how much of the authorised issue is still unsold. Movement matters as much as + * position — a society whose membership fell while its capital rose has done something quite different from one where + * both grew — so each figure is reported as opening, movement during the year, and closing, against the same three for + * the year before. + * + * Every figure is derived from share accounts and their transactions rather than from any stored summary, so the return + * cannot drift from the register it is meant to describe. + */ +@Path("/v1/shareproducts/capital-return") +@Component +@Tag(name = "Share Capital Return", description = "Members and share capital per share type, current year against previous, for RBI reporting.") +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareCapitalReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "SHAREPRODUCT"; + /** Fineract share account/transaction status: 300 = ACTIVE / APPROVED. */ + private static final int STATUS_ACTIVE = 300; + /** Share transaction type: 500 = purchase, 600 = redemption. */ + private static final int TYPE_PURCHASE = 500; + private static final int TYPE_REDEEM = 600; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * The return for one financial year, per share type, with the prior year beside it. + * + * The year is given as its start year — 2025 means FY 2025-26 — because a financial year is named by the year it + * opens in, and asking a caller to send two dates invites a period that is not a financial year at all. + */ + @GET + public Response report(@QueryParam("financialYear") final Integer financialYearStart) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final int startYear = financialYearStart != null ? financialYearStart : currentFinancialYearStart(); + final LocalDate from = LocalDate.of(startYear, 4, 1); + final LocalDate to = LocalDate.of(startYear + 1, 3, 31); + final LocalDate priorFrom = from.minusYears(1); + final LocalDate priorTo = to.minusYears(1); + + final JsonArray products = new JsonArray(); + for (final Map product : jdbcTemplate.queryForList(""" + SELECT id, name, short_name, currency_code, total_shares, issued_shares, unit_price, capital_amount + FROM m_share_product ORDER BY id + """)) { + final Long productId = ((Number) product.get("id")).longValue(); + + final JsonObject entry = new JsonObject(); + entry.addProperty("productId", productId); + entry.addProperty("shareType", (String) product.get("name")); + entry.addProperty("shortName", (String) product.get("short_name")); + entry.addProperty("currency", (String) product.get("currency_code")); + entry.addProperty("unitPrice", (BigDecimal) product.get("unit_price")); + + // What the society is permitted to issue, against what it actually has. The gap is the + // headroom a supervisor checks a fresh issue against. + final long authorisedShares = asLong(product.get("total_shares")); + entry.addProperty("authorisedShares", authorisedShares); + entry.addProperty("authorisedCapital", (BigDecimal) product.get("capital_amount")); + + entry.add("currentYear", positionFor(productId, from, to)); + entry.add("previousYear", positionFor(productId, priorFrom, priorTo)); + + // Allotted out of authorised, as at the year end being reported — not today, or the + // return would answer a different question than the one it was asked. + final long allottedShares = sharesHeldAsAt(productId, to); + entry.addProperty("allottedShares", allottedShares); + entry.addProperty("unissuedShares", Math.max(0, authorisedShares - allottedShares)); + entry.addProperty("utilisationPercentage", authorisedShares == 0 ? null + : BigDecimal.valueOf(allottedShares).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(authorisedShares), 2, java.math.RoundingMode.HALF_UP)); + products.add(entry); + } + + final JsonObject out = new JsonObject(); + out.addProperty("financialYear", startYear + "-" + String.format("%02d", (startYear + 1) % 100)); + out.addProperty("previousFinancialYear", + (startYear - 1) + "-" + String.format("%02d", startYear % 100)); + out.addProperty("periodStartDate", from.toString()); + out.addProperty("periodEndDate", to.toString()); + out.add("shareTypes", products); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * One share type's position over one financial year: members and capital at the start, what moved during it, and + * where both ended. + * + * Members are counted by distinct client rather than by account, because one member holding three share accounts is + * still one member — counting accounts would overstate the membership a supervisor is being told. + */ + private JsonObject positionFor(final Long productId, final LocalDate from, final LocalDate to) { + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date < ?) AS members_opening, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date BETWEEN ? AND ?) AS members_admitted, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.closed_date BETWEEN ? AND ?) AS members_ceased, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date <= ? + AND (sa.closed_date IS NULL OR sa.closed_date > ?)) AS members_closing + FROM m_share_account sa + WHERE sa.product_id = ? AND sa.status_enum = ? + """, java.sql.Date.valueOf(from), java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), + java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), + productId, STATUS_ACTIVE); + + final Map capital = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.amount ELSE -t.amount END) + FILTER (WHERE t.transaction_date < ?), 0) AS capital_opening, + COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS capital_issued, + COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS capital_redeemed, + COALESCE(SUM(t.total_shares) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS shares_issued + 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 = ? + AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + """, TYPE_PURCHASE, java.sql.Date.valueOf(from), TYPE_PURCHASE, java.sql.Date.valueOf(from), + java.sql.Date.valueOf(to), TYPE_REDEEM, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), TYPE_PURCHASE, + java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE, + TYPE_REDEEM); + + final BigDecimal opening = (BigDecimal) capital.get("capital_opening"); + final BigDecimal issued = (BigDecimal) capital.get("capital_issued"); + final BigDecimal redeemed = (BigDecimal) capital.get("capital_redeemed"); + + final JsonObject out = new JsonObject(); + out.addProperty("membersOpening", asLong(row.get("members_opening"))); + out.addProperty("membersAdmitted", asLong(row.get("members_admitted"))); + out.addProperty("membersCeased", asLong(row.get("members_ceased"))); + out.addProperty("membersClosing", asLong(row.get("members_closing"))); + out.addProperty("capitalOpening", opening); + out.addProperty("capitalIssued", issued); + out.addProperty("capitalRedeemed", redeemed); + // Closing is opening plus what moved, not a separate query: the two must agree, and deriving + // it is what guarantees they do. + out.addProperty("capitalClosing", opening.add(issued).subtract(redeemed)); + out.addProperty("sharesIssued", asLong(capital.get("shares_issued"))); + return out; + } + + /** Shares still held in this product as at a date — purchases less redemptions. */ + private long sharesHeldAsAt(final Long productId, final LocalDate asAt) { + final Long held = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.total_shares ELSE -t.total_shares END), 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 = ? + AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + AND t.transaction_date <= ? + """, Long.class, TYPE_PURCHASE, productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE, TYPE_REDEEM, + java.sql.Date.valueOf(asAt)); + return held == null ? 0L : held; + } + + /** April opens the Indian financial year, so anything before it still belongs to the prior one. */ + private int currentFinancialYearStart() { + final LocalDate today = LocalDate.now(java.time.ZoneId.systemDefault()); + return today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1; + } + + private long asLong(final Object value) { + return value == null ? 0L : ((Number) value).longValue(); + } + + /** The financial years that actually have share activity, newest first, for the year picker. */ + @GET + @Path("/financial-years") + public Response financialYears() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final List years = jdbcTemplate.queryForList(""" + SELECT DISTINCT CASE WHEN EXTRACT(MONTH FROM t.transaction_date) >= 4 + THEN EXTRACT(YEAR FROM t.transaction_date) + ELSE EXTRACT(YEAR FROM t.transaction_date) - 1 END AS fy_start + FROM m_share_account_transactions t + WHERE t.is_active = true AND t.transaction_date IS NOT NULL + ORDER BY fy_start DESC + """, Integer.class); + + final JsonArray out = new JsonArray(); + // The current year is always offered even with no activity yet — a return is filed for the + // year that is running, not only for years that already have movement in them. + final int current = currentFinancialYearStart(); + if (!years.contains(current)) { + out.add(yearEntry(current)); + } + for (final Integer year : years) { + if (year != null) { + out.add(yearEntry(year)); + } + } + return Response.ok(gson.toJson(out)).build(); + } + + private JsonObject yearEntry(final int startYear) { + final JsonObject entry = new JsonObject(); + entry.addProperty("startYear", startYear); + entry.addProperty("label", startYear + "-" + String.format("%02d", (startYear + 1) % 100)); + return entry; + } + + @SuppressWarnings("unused") + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("defaultUserMessage", message); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} 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 index aa99cbaf3..bab29c66e 100644 --- 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 @@ -200,7 +200,12 @@ public class ShareDividendCapitalPreviewApiResource { } final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500; - if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) { + // The + 1 matches ShareProductDividendAssembler: the period end is itself a day the + // shares were held, so the holding runs to the day after it. Without it a full financial + // year measures 364 days, a product asking for the obvious 365 qualifies nobody, and this + // preview disagrees with the declaration it is previewing. + final long daysHeld = java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) + 1; + if (isPurchase && daysHeld < minimumActivePeriod) { continue; } 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 b742bbe0a..b08c8c70a 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 @@ -154,7 +154,12 @@ public class ShareProductDividendAssembler { if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) { shareStartDate = lastDividendPostDate; } - int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate); + // The period end is itself a day the member held the shares, so the holding is + // measured to the day AFTER it. Without the +1 a dividend period is one day + // short of the year it represents — 01-Apr-2025 to 31-Mar-2026 subtracts to 364 — + // and a product whose minimum active period is the obvious 365 could never pay a + // dividend for any single financial year, however long the member had held. + int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate) + 1; if (type.isPurchased() && numberOfPurchseDays < minimumActivePeriod) { continue; } 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 62624f7d1..ef128c757 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 @@ -91,6 +91,15 @@ 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_PAYABLE_DIVIDENDS = 201; + + /** + * How many financial years of dividend a member may still come and claim. + * + * A society keeps an unclaimed dividend available for three years. Declaring a fourth year is the point the oldest + * one falls out of that window: the member had three full years of the society asking them to collect it, and the + * money is recognised as income rather than sitting as a liability nobody will ever call on. + */ + private static final int CLAIMABLE_FINANCIAL_YEARS = 3; private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; /** @@ -105,11 +114,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP */ 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; @@ -122,9 +126,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP 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) { try { @@ -203,8 +204,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails); final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId); - postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate, - dividendPeriodEndDate, financialYear); + // The statutory reserve and education fund are NOT appropriated here. They are a division + // of the year's profit that happens on its own screen before any dividend is declared, so + // posting them again on declaration would move the same money twice. postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount, financialYear); recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount, @@ -282,8 +284,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP if (netProfit.signum() <= 0) { return; } - final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE)) - .subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE)); + // The balance in the profit GL is already net of the statutory reserve and education fund — + // those were appropriated on the profit-distribution screen before this dividend was + // declared, so deducting them again would sweep away money that is no longer there. + final BigDecimal distributable = netProfit; // 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. @@ -436,19 +440,22 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * 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. + * A dividend must not commit more money than the profit account holds. * - * 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. + * The amount itself is settled before this runs — it is the declared rate on members' paid-up capital, and profit + * plays no part in working it out. This is purely a funding check: whatever the rate produced, the society has to + * actually have it. * - * 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. + * What it is checked against is the profit remaining in the Bank Profit GL, which is already net of the statutory + * reserve and education fund — those are appropriated on the profit-distribution screen before any dividend is + * declared, so deducting them again here would hold back money that has already left the account. + * + * Every dividend already declared against an overlapping period for ANY share product is subtracted too, since the + * profit is society-wide rather than per-product. Without that, two products could each be declared the whole + * balance and the society would have committed twice the money it had. + * + * The ceiling is enforced here rather than on the screen 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. */ private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) { @@ -462,25 +469,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP + " 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. + // Every product draws on the same 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); + final BigDecimal available = netProfit.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."); + "This dividend of " + dividendAmount + " is more than the profit available to fund it for this period. Bank profit " + + netProfit + ", of which " + alreadyDeclared + " is already declared — only " + available.max(BigDecimal.ZERO) + + " remains available."); } } @@ -540,70 +542,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP 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() @@ -742,6 +680,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP releaseReserveToDividendPayable(dividendPayOutDetails); sweepRoundingResidueOnApproval(PayOutDetailId); makeDividendsClaimable(PayOutDetailId); + forfeitDividendsBeyondClaimWindow(dividendPayOutDetails); closeOffUndistributedProfit(dividendPayOutDetails); return new CommandProcessingResultBuilder() // .withEntityId(PayOutDetailId) // @@ -762,6 +701,82 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * 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. */ + /** + * Forfeits any dividend that has fallen outside the three-year claim window, now that a newer year has been + * approved. + * + *
+     *   Dr  Dividend Payable            — the obligation ends; nobody is coming to claim it
+     *       Cr  Bank Profit
+     * 
+ * + * A society keeps three financial years of dividend claimable. Approving a fourth is what pushes the oldest out: + * counted by financial year rather than by elapsed days, so "three years of dividend" means what a member and an + * auditor both take it to mean, and a declaration made late does not buy the member extra time. + * + * Only UNCLAIMED locks are touched. A claimed one has already been paid out by draft and its money left Dividend + * Payable long ago. + * + * Credited straight to Bank Profit rather than routed through an income account: the money never left the society, + * so this is a liability that turned out not to be owed, and it returns directly to the retained profit it was + * declared out of. + * + * Skipped when the GL accounts are not mapped, exactly like every other entry here: incomplete GL setup must never + * block an approval. + */ + private void forfeitDividendsBeyondClaimWindow(final ShareProductDividendPayOutDetails declaration) { + final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate(); + if (periodEndDate == null) { + return; + } + + // Everything ending more than three financial years before the one just approved. Compared on + // the period end date, which is what identifies a declaration's financial year. + final LocalDate oldestClaimablePeriodEnd = periodEndDate.minusYears(CLAIMABLE_FINANCIAL_YEARS); + + final List> expired = jdbcTemplate.queryForList(""" + SELECT l.id, l.amount, l.currency_code, 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.status = 'UNCLAIMED' AND d.dividend_period_end_date <= ? + """, java.sql.Date.valueOf(oldestClaimablePeriodEnd)); + if (expired.isEmpty()) { + return; + } + + final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + if (payableDividendsAccountId == null || bankProfitAccountId == null) { + return; + } + + final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + for (final Map lock : expired) { + final Long lockId = ((Number) lock.get("id")).longValue(); + final BigDecimal amount = (BigDecimal) lock.get("amount"); + if (amount == null || amount.signum() <= 0) { + continue; + } + final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate()); + + postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"), + "DIVFORF" + lockId, entryDate, + "FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS + + " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate)); + + jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); + } + } + + /** A GL account mapped to a financial activity, or null when the society has not configured it. */ + private Long glAccountIdByFinancialActivity(final int financialActivityType) { + return jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + financialActivityType) + .stream().findFirst().orElse(null); + } + private void makeDividendsClaimable(final Long payOutDetailId) { final LocalDate today = DateUtils.getBusinessLocalDate(); final java.sql.Date lockedDate = java.sql.Date.valueOf(today); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index 792daa49c..e27e53176 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -23,10 +23,12 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -37,6 +39,8 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; @@ -47,6 +51,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor +@Slf4j public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService { private static final String STATUS_PENDING = "PENDING"; @@ -56,6 +61,22 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private static final String METHOD_CHEQUE = "CHEQUE"; private static final String METHOD_CASH = "CASH"; + /** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */ + private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST"; + + /** + * A draft the society raises to pay a member their own dividend. + * + * Unlike every other method there is no purchaser: the society already owes the money, so nothing is collected and + * the draft is funded out of the Dividend Payable liability instead. Raised by the dividend claim flow, approved + * here alongside every other draft. + */ + private static final String METHOD_DIVIDEND = "DIVIDEND"; + + /** Financial activity ids for the dividend liability accounts (see the dividend claim flow). */ + private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; + // Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing, // so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit // aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which @@ -67,6 +88,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; private final FineractRequestContextHolder fineractRequestContextHolder; private final SponsorDDWritePlatformService sponsorDDWritePlatformService; @@ -471,6 +493,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } } else if (METHOD_CHEQUE.equals(paymentMethod)) { drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } else if (METHOD_DIVIDEND.equals(paymentMethod)) { + // Nothing is collected from the member — the society already owes them this money — so + // the draft is funded by settling the Dividend Payable liability instead of debiting a + // purchaser. Routed through Dividend Paid so the society can read what has been claimed + // and what is still unclaimed as two separate balances rather than only the net. + drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber); } else { // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the // request was submitted, so approval clears the suspense rather than debiting cash again. @@ -495,10 +523,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, issueRequestId); + if (METHOD_DIVIDEND.equals(paymentMethod)) { + settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName()); + } + + raiseIssueVoucher(issueRequestId, ddNumber, drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, chargesGlAccountId, + gstAmount, gstGlAccountId, currentUser); + return new CommandProcessingResultBuilder().withEntityId(issueRequestId) .withSubEntityId(issueResult.getResourceId()).build(); } + /** + * Closes off the dividend claim this draft was raised for, now that the draft actually exists. + * + * Approving the draft IS approving the claim — they are the same decision — so the claim is marked APPROVED here + * rather than waiting on a second sign-off in another screen. + * + * A claim for less than the whole lock leaves the remainder UNCLAIMED for a later visit; a claim for all of it + * closes the lock. Both are what the member is actually owed afterwards, so the lock has to move either way. + * + * Silent when no claim is attached: a DIVIDEND draft raised any other way is still a valid draft, and refusing to + * approve it would strand money the society owes. + */ + private void settleDividendClaim(final Long issueRequestId, final String ddNumber, final BigDecimal faceValue, final Long checkerId, + final String checkerName) { + + final Map claim = this.jdbcTemplate + .queryForList("SELECT id, lock_id FROM m_share_dividend_claim_request WHERE dd_issue_request_id = ? AND status = 'PENDING'", + issueRequestId) + .stream().findFirst().orElse(null); + if (claim == null) { + return; + } + final Long claimId = ((Number) claim.get("id")).longValue(); + final Long lockId = ((Number) claim.get("lock_id")).longValue(); + + final BigDecimal lockAmount = this.jdbcTemplate + .queryForList("SELECT amount FROM m_share_dividend_lock WHERE id = ?", BigDecimal.class, lockId).stream().findFirst() + .orElse(BigDecimal.ZERO); + final BigDecimal remainder = lockAmount.subtract(faceValue); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, + lockId); + } else { + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", + lockId); + } + + this.jdbcTemplate.update(""" + UPDATE m_share_dividend_claim_request + SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), dd_number = ? + WHERE id = ? + """, checkerId, checkerName, ddNumber, claimId); + } + @Override @Transactional public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) { @@ -540,6 +619,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "cash_refund_journal_entry_id = ? WHERE id = ?", STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId); + if (METHOD_DIVIDEND.equals(paymentMethod)) { + // Rejecting the draft rejects the claim it was raised for — nothing was posted and the + // lock was never touched, so the dividend simply stays UNCLAIMED and can be claimed + // again. Leaving the claim PENDING would block that with a request nobody will action. + this.jdbcTemplate.update(""" + UPDATE m_share_dividend_claim_request + SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? + WHERE dd_issue_request_id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), rejectionReason, issueRequestId); + } + return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build(); } @@ -1068,7 +1158,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); return result.getResourceId().toString(); } @@ -1087,7 +1180,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); return result.getResourceId().toString(); } @@ -1121,6 +1217,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } + /** + * Records an approved Demand Draft issue in the voucher register. + * + * The slip mirrors the posting: the funding side is debited for everything the purchaser parts + * with, and the proceeds are split the way the ledger splits them — the draft's face value to + * the DD Payable liability, and the charges and GST to their own income and tax accounts rather + * than being folded into the liability. + * + * The debit is the total, not the face value: a purchaser handing over face value plus charges + * has parted with the total, and a slip showing only the face value would not match what they + * paid. + * + * A failure here does not fail the issue. The draft is already raised, the leaflet already + * consumed and the ledger already posted; withdrawing that over a slip would take back a + * completed instrument for a paperwork reason. The problem is logged and the issue stands. + */ + private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId, + final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId, + final BigDecimal gstAmount, final Long gstGlAccountId, final AppUser currentUser) { + try { + final BigDecimal charges = chargesAmount == null ? BigDecimal.ZERO : chargesAmount; + final BigDecimal gst = gstAmount == null ? BigDecimal.ZERO : gstAmount; + final BigDecimal total = faceValue.add(charges).add(gst); + + final List lines = new ArrayList<>(); + + final Map funding = this.voucherService.glAccount(fundingGlAccountId); + lines.add(VoucherLine + .debitGl(fundingGlAccountId, (String) funding.get("name"), (String) funding.get("gl_code"), total) + .withDescription("Sponsor DD " + ddNumber)); + + final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); + lines.add(VoucherLine + .creditGl(ddPayableGlAccountId, (String) ddPayable.get("name"), (String) ddPayable.get("gl_code"), faceValue) + .withDescription("Draft " + ddNumber + " face value")); + + if (charges.signum() > 0 && chargesGlAccountId != null) { + final Map chargesGl = this.voucherService.glAccount(chargesGlAccountId); + lines.add(VoucherLine + .creditGl(chargesGlAccountId, (String) chargesGl.get("name"), (String) chargesGl.get("gl_code"), charges) + .withDescription("DD issuance charges")); + } + if (gst.signum() > 0 && gstGlAccountId != null) { + final Map gstGl = this.voucherService.glAccount(gstGlAccountId); + lines.add(VoucherLine.creditGl(gstGlAccountId, (String) gstGl.get("name"), (String) gstGl.get("gl_code"), gst) + .withDescription("GST on DD charges")); + } + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId, + currentUser, VoucherService.PREFIX_DEBIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e); + } + } + /** * Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for @@ -1140,6 +1291,46 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI txnDate, comments, linkedAccountId); } + /** + * Settles the member's dividend entitlement and returns the account the draft is then drawn against. + * + *
+     *   Dr  Dividend Payable      Cr  Dividend Paid     (here — the member's entitlement is discharged)
+     *   Dr  Dividend Paid         Cr  Sponsor DD Payable (by the caller — the draft is raised)
+     * 
+ * + * Routed through Dividend Paid rather than posted straight across so the society can read what has been claimed + * and what is still unclaimed as two separate balances instead of only the net of Dividend Payable. Dividend Paid + * nets to nil, leaving its credit total as the cumulative dividend claimed. + * + * A society that has not mapped Dividend Paid still gets a correct payout — Dividend Payable is returned and the + * caller posts straight across to DD Payable — just without that separate measure. + */ + private Long resolveDividendFundingGlAccount(final BigDecimal faceValue, final Long officeId, final LocalDate issuedDate, + final String ddNumber) { + + final Long payableDividendsAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); + if (payableDividendsAccountId == null) { + throw new IllegalStateException("No Payable Dividends GL account is mapped. Ask an administrator to configure the " + + "dividend financial activity accounts before approving a dividend Demand Draft."); + } + final Long dividendPaidAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_DIVIDEND_PAID); + if (dividendPaidAccountId == null) { + return payableDividendsAccountId; + } + + postGlOffsetEntry(payableDividendsAccountId, dividendPaidAccountId, faceValue, null, null, officeId, issuedDate, + "Dividend paid by Demand Draft " + ddNumber + " — member's entitlement settled", null); + return dividendPaidAccountId; + } + + private Long financialActivityGlAccount(final int financialActivityType) { + return this.jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + financialActivityType) + .stream().findFirst().orElse(null); + } + /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, @@ -1192,7 +1383,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); final String transactionId = result != null ? result.getTransactionId() : null; if (transactionId == null) return null; 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 index 2c45c36d1..ab901f3b3 100644 --- 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 @@ -33,13 +33,16 @@ import jakarta.ws.rs.core.Response; import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; import java.util.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.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -68,11 +71,12 @@ public class VoucherVerificationApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final VoucherService voucherService; 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. + // CR/2108/0001 and Spring Security's strict firewall rejects the encoded slashes. @POST @Path("verify") @@ -412,6 +416,223 @@ public class VoucherVerificationApiResource { return gson.toJson(Map.of("resourceId", id, "deleted", true)); } + // ── GET /v1/vouchers/report?from=2026-08-01&to=2026-08-31&username=rajesh ── + + /** + * The vouchers raised over a period, one row each, with the accounts they moved money between. + * + * Who sees whose work is decided here rather than by the caller: a clerk, head cashier or + * manager sees every user's vouchers and may filter to one; anyone else sees only their own, + * whatever they ask for. A cashier cannot read a colleague's day by passing their username. + * + * Each row carries the debit and credit sides already joined, so the report reads as a register + * — voucher number, what was debited, what was credited — without a second call per row. A + * voucher with more than two sides (a DD with charges, a compound journal) is summarised by its + * first debit and first credit with a count of the rest, because a register is a list of + * movements and the full breakdown belongs on the voucher itself. + */ + @GET + @Path("report") + public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, + @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) { + + final AppUser currentUser = this.context.authenticatedUser(); + final boolean seesEveryone = isCheckerRole(currentUser); + + // Defaults to today rather than to everything: an unbounded register over a year of + // vouchers is a report nobody asked for and a query that scans the whole table. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate fromDate = parseReportDate(from, today); + final LocalDate toDate = parseReportDate(to, today); + if (toDate.isBefore(fromDate)) { + throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid", + "The end of the period cannot fall before its start."); + } + + // A user who may only see their own work is pinned to their own username regardless of what + // they asked for; a checker's blank filter means everyone. + final String scopedUsername = seesEveryone ? (username == null || username.isBlank() ? null : username.trim()) + : currentUser.getUsername(); + + final StringBuilder sql = new StringBuilder(""" + SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name, + ver.status AS verification_status, + dr.party_name AS debit_party, dr.account_no AS debit_account, + cr.party_name AS credit_party, cr.account_no AS credit_account, + (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count + FROM m_voucher v + LEFT JOIN m_office o ON o.id = v.branch_office_id + LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'DR' + ORDER BY line_no LIMIT 1) dr ON TRUE + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'CR' + ORDER BY line_no LIMIT 1) cr ON TRUE + WHERE v.created_at >= ? AND v.created_at < ? + """); + + final List args = new java.util.ArrayList<>(); + args.add(Timestamp.valueOf(fromDate.atStartOfDay())); + // Exclusive upper bound on the day after, so a voucher raised at 23:59 on the last day is + // still inside the period — a BETWEEN on dates would drop it. + args.add(Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); + + if (scopedUsername != null) { + sql.append(" AND v.created_by_username = ?"); + args.add(scopedUsername); + } + if (voucherType != null && !voucherType.isBlank()) { + sql.append(" AND v.voucher_type = ?"); + args.add(voucherType.trim()); + } + sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC"); + + final List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + BigDecimal total = BigDecimal.ZERO; + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + if (amount != null) { + total = total.add(amount); + } + + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", r.get("voucher_no")); + m.put("voucherType", r.get("voucher_type")); + m.put("amount", amount); + m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); + m.put("userId", r.get("created_by")); + m.put("username", r.get("created_by_username")); + m.put("branchName", r.get("branch_name")); + m.put("verificationStatus", r.get("verification_status")); + m.put("debitParty", r.get("debit_party")); + m.put("debitAccount", r.get("debit_account")); + m.put("creditParty", r.get("credit_party")); + m.put("creditAccount", r.get("credit_account")); + // Anything beyond one debit and one credit is extra detail the register does not show, + // so the row says how much it is hiding rather than misrepresenting the voucher. + final int lineCount = ((Number) r.get("line_count")).intValue(); + m.put("extraLines", Math.max(0, lineCount - 2)); + out.add(m); + } + + final Map res = new LinkedHashMap<>(); + res.put("from", fromDate.toString()); + res.put("to", toDate.toString()); + res.put("username", scopedUsername); + // Tells the screen whether to offer the user filter at all, rather than having it guess + // from the caller's roles a second time. + res.put("canSeeAllUsers", seesEveryone); + res.put("vouchers", out); + res.put("count", out.size()); + res.put("totalAmount", total); + return gson.toJson(res); + } + + /** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */ + private static LocalDate parseReportDate(final String value, final LocalDate fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + try { + return LocalDate.parse(value.trim()); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.voucher.report.date.invalid", + "Dates must be written as yyyy-MM-dd; got '" + value + "'."); + } + } + + // ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ──────────────────────── + // A query parameter for the same reason as verify: a formatted voucher number contains + // slashes, which the strict firewall rejects even encoded, so it cannot be a path segment. + + /** + * One voucher and the debit and credit sides it is made of — what a printed voucher shows. + * + * The sides come back in print order, with each line's running side total, so the caller can + * lay out a slip without re-deriving anything. A voucher raised before lines were recorded + * returns an empty list rather than an error: the voucher is real, its posting simply was not + * captured at the time, and reporting it as missing would be wrong. + */ + @GET + @Path("detail") + public String detail(@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> header = jdbcTemplate.queryForList(""" + SELECT v.voucher_no, v.voucher_type, v.reference_type, v.reference_id, v.amount, + v.branch_office_id, o.name AS branch_name, + v.created_by_username, v.created_at, + ver.status AS verification_status + FROM m_voucher v + LEFT JOIN m_office o ON o.id = v.branch_office_id + LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no + WHERE v.voucher_no = ? + """, voucherNo); + + if (header.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in the register."); + } + + final Map h = header.get(0); + final List> lineRows = voucherService.findLines(voucherNo); + + BigDecimal totalDebit = BigDecimal.ZERO; + BigDecimal totalCredit = BigDecimal.ZERO; + final List> lines = new java.util.ArrayList<>(); + for (final Map r : lineRows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + final String entryType = (String) r.get("entry_type"); + if ("DR".equals(entryType)) { + totalDebit = totalDebit.add(amount); + } else { + totalCredit = totalCredit.add(amount); + } + + final Map m = new LinkedHashMap<>(); + m.put("lineNo", r.get("line_no")); + m.put("entryType", entryType); + m.put("glAccountId", r.get("gl_account_id")); + m.put("savingsAccountId", r.get("savings_account_id")); + m.put("loanAccountId", r.get("loan_account_id")); + m.put("partyName", r.get("party_name")); + m.put("accountNo", r.get("account_no")); + m.put("amount", amount); + m.put("description", r.get("description")); + lines.add(m); + } + + final Map out = new LinkedHashMap<>(); + out.put("voucherNo", h.get("voucher_no")); + out.put("voucherType", h.get("voucher_type")); + out.put("referenceType", h.get("reference_type")); + out.put("referenceId", h.get("reference_id")); + out.put("amount", h.get("amount")); + out.put("branchOfficeId", h.get("branch_office_id")); + out.put("branchName", h.get("branch_name")); + out.put("createdBy", h.get("created_by_username")); + out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString()); + out.put("verificationStatus", h.get("verification_status")); + out.put("lines", lines); + out.put("totalDebit", totalDebit); + out.put("totalCredit", totalCredit); + // Stated rather than assumed: a voucher whose sides were written before the balance check + // existed, or repaired by hand in the database, should show as unbalanced rather than be + // presented as a valid slip. + out.put("balanced", totalDebit.compareTo(totalCredit) == 0); + return gson.toJson(out); + } + /** * 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. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java new file mode 100644 index 000000000..a5aa781de --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java @@ -0,0 +1,95 @@ +/** + * 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.service; + +import java.math.BigDecimal; + +/** + * One side of one voucher: a debit or a credit against a single account. + * + * The account is named by whichever of the three ids is set — GL, savings or loan. A voucher is + * built by pairing these up, so a GL-to-GL journal, a GL-to-account dividend payout and an + * account-to-account transfer are the same structure with different sides filled in, rather than + * three separate shapes. + * + * partyName and accountNo are carried on the line rather than looked up when the voucher is + * printed: a voucher has to keep showing what was actually posted, even after a member is renamed + * or an account is renumbered. + * + * @param entryType + * {@link VoucherService#ENTRY_DEBIT} or {@link VoucherService#ENTRY_CREDIT} + * @param glAccountId + * set when this side posts against a GL account + * @param savingsAccountId + * set when this side posts against a member's savings account + * @param loanAccountId + * set when this side posts against a loan account + * @param partyName + * what the voucher prints for this side — "Cash in Hand", or the member's name + * @param accountNo + * the GL code or account number as it appears on the slip + * @param amount + * always positive; the side is carried by entryType, not by the sign + * @param description + * optional narration for this line alone + */ +public record VoucherLine(String entryType, Long glAccountId, Long savingsAccountId, Long loanAccountId, String partyName, + String accountNo, BigDecimal amount, String description) { + + /** A debit against a GL account. */ + public static VoucherLine debitGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, glAccountId, null, null, name, code, amount, null); + } + + /** A credit against a GL account. */ + public static VoucherLine creditGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, glAccountId, null, null, name, code, amount, null); + } + + /** A debit against a member's savings account — money leaving the member. */ + public static VoucherLine debitSavings(final Long savingsId, final String party, final String accountNo, + final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, null, savingsId, null, party, accountNo, amount, null); + } + + /** A credit against a member's savings account — money reaching the member. */ + public static VoucherLine creditSavings(final Long savingsId, final String party, final String accountNo, + final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, null, savingsId, null, party, accountNo, amount, null); + } + + /** A debit against a loan account. */ + public static VoucherLine debitLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, null, null, loanId, party, accountNo, amount, null); + } + + /** A credit against a loan account. */ + public static VoucherLine creditLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, null, null, loanId, party, accountNo, amount, null); + } + + /** The same line carrying a narration, for the cases where one side needs its own reason. */ + public VoucherLine withDescription(final String text) { + return new VoucherLine(entryType, glAccountId, savingsAccountId, loanAccountId, partyName, accountNo, amount, text); + } + + public boolean isDebit() { + return VoucherService.ENTRY_DEBIT.equals(entryType); + } +} 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 32bb2cbd8..9190879ce 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 @@ -35,15 +35,19 @@ import org.springframework.stereotype.Service; /** * Central voucher register (m_voucher). Every maker-checker cash transaction * (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 form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and + * DR/2108/0001 for a withdrawal on 21 August. * * 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. + * prefix, so a deposit and a withdrawal on the same day never share a number. + * The series is bank-wide rather than per-branch: the number names the slip + * side and the day, and nothing else. The office that raised the voucher is + * still recorded, in m_voucher.branch_office_id, so a per-branch register + * remains reportable without the branch appearing in the printed number. + * + * 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 @@ -56,20 +60,84 @@ public class VoucherService { public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; + public static final String TYPE_REPAYMENT = "REPAYMENT"; + public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING"; 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"; + /** A voucher raised directly as a journal, rather than by one of the cash registers. */ + public static final String TYPE_JOURNAL = "JOURNAL"; + + /** The debit side of a voucher line. */ + public static final String ENTRY_DEBIT = "DR"; + /** The credit side of a voucher line. */ + public static final String ENTRY_CREDIT = "CR"; + /** 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"; + /** + * A journal voucher, which has no single side to name it by: a transfer between two GLs is + * neither a receipt nor a payment, and calling it CR or DR would pick one arbitrarily. It + * carries its own daily series for the same reason. + */ + public static final String PREFIX_JOURNAL = "JV"; + + /** A loan disbursement or repayment, keyed by its m_loan_transaction id. */ + public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION"; + + /** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */ + public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY"; private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; + /** + * Set while a register is posting the accounting behind a voucher it has already raised. + * + * Several registers do not write their own ledger entries: a cashier GL deposit, a clerk + * disbursement and a Sponsor DD issue all post theirs by invoking the ordinary journal-entry + * command, and a DD funded from a member's account does it by invoking the ordinary savings + * withdrawal. Each of those inner operations raises a voucher of its own, which here would be a + * second voucher over the same money — and the inner one is the worse of the two, naming a bare + * journal or a plain withdrawal where the outer register names the member and the instrument. + * + * A register wraps its posting in {@link #withOwnVoucher} to say the voucher is already its + * responsibility. A ThreadLocal rather than a request attribute, because the same holds for a + * batch job, which has no request to hang the flag on. + */ + private static final ThreadLocal OUTER_REGISTER_OWNS_VOUCHER = ThreadLocal.withInitial(() -> Boolean.FALSE); + + /** + * Runs {@code posting} with any voucher the inner operation would raise suppressed, for a + * register that has already raised its own. + * + * Restores the previous value rather than clearing it, so nesting — a register posting twice, + * or one posting routine calling another — cannot switch the suppression off early for the + * outer caller. + */ + public T withOwnVoucher(final java.util.function.Supplier posting) { + final boolean previous = OUTER_REGISTER_OWNS_VOUCHER.get(); + OUTER_REGISTER_OWNS_VOUCHER.set(Boolean.TRUE); + try { + return posting.get(); + } finally { + OUTER_REGISTER_OWNS_VOUCHER.set(previous); + } + } + + /** + * Whether the operation now in progress should raise its own voucher, or leave it to a register + * further out that has already raised one. + */ + public boolean shouldRaiseVoucher() { + return !OUTER_REGISTER_OWNS_VOUCHER.get(); + } + /** * 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). @@ -78,36 +146,35 @@ public class VoucherService { 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. + * Reserves the next counter value for one (prefix, 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. + * + * branch_code is written as the empty string rather than dropped: the column is + * NOT NULL from the original schema, and one bank-wide series per (prefix, day) + * means there is no longer a branch to put there. */ - private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) { + private int nextSeriesNumber(final String prefix, 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) + VALUES (?,'',?,1) + ON CONFLICT (prefix, series_date) DO UPDATE SET last_number = m_voucher_sequence.last_number + 1 RETURNING last_number """, - Integer.class, prefix, branch, Date.valueOf(day)); + Integer.class, prefix, 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. + * "CR/2108/0001". The office that handled the cash is taken from the acting + * user rather than from any branch the screen happens to have selected, and + * is stored alongside the voucher instead of being printed into its number. */ public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user) { @@ -124,28 +191,236 @@ public class VoucherService { public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user, final String prefix) { + return createVoucher(voucherType, referenceType, referenceId, null, amount, user, prefix); + } + + /** + * As above, but for a source identified by text rather than by a number — a manual journal, + * which is known by its transaction id. Exactly one of referenceId and referenceKey is + * expected to be set; a source uses whichever identifier it actually has. + */ + public String createVoucher(final String voucherType, final String referenceType, + final Long referenceId, final String referenceKey, 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); + final int serial = nextSeriesNumber(prefix, today); + final String voucherNo = String.format("%s/%s/%04d", + prefix, today.format(DAY_MONTH), serial); + + final Long branchOfficeId = user.getOffice() != null ? user.getOffice().getId() : null; jdbcTemplate.update(""" INSERT INTO m_voucher - (voucher_no, voucher_type, reference_type, reference_id, amount, - created_by, created_by_username, created_at) - VALUES (?,?,?,?,?,?,?,?) + (voucher_no, voucher_type, reference_type, reference_id, reference_key, amount, + branch_office_id, created_by, created_by_username, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?) """, - voucherNo, voucherType, referenceType, referenceId, amount, - user.getId(), user.getUsername(), Timestamp.from(Instant.now()) + voucherNo, voucherType, referenceType, referenceId, referenceKey, amount, + branchOfficeId, user.getId(), user.getUsername(), Timestamp.from(Instant.now()) ); return voucherNo; } + /** + * Creates a voucher together with the debit and credit sides that make it up, and returns the + * formatted voucher number. + * + * This is the form to reach for whenever the posting is known at the point the voucher is + * raised. The amount stored on the voucher is the total of one side, which — the two sides + * being equal — is the value of the voucher as a whole. + * + * @throws IllegalArgumentException + * if the lines do not balance, or do not include both a debit and a credit. A + * voucher that does not balance is not a voucher, and letting one reach the + * register would put the trial balance out by exactly the difference. + */ + public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId, + final AppUser user, final String prefix, final List lines) { + return createVoucherWithLines(voucherType, referenceType, referenceId, null, user, prefix, lines); + } + + /** As above, for a source identified by text rather than by a number. */ + public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId, + final String referenceKey, final AppUser user, final String prefix, final List lines) { + + final BigDecimal total = validateBalanced(lines); + final String voucherNo = createVoucher(voucherType, referenceType, referenceId, referenceKey, total, user, prefix); + writeLines(voucherNo, lines); + return voucherNo; + } + + /** + * Checks that the lines form a voucher, and returns the value of one side. + * + * Debits and credits are compared by {@link BigDecimal#compareTo}, not equals, so that two + * figures differing only in scale — 5000 and 5000.00 — count as the same amount rather than + * failing a voucher that is in fact balanced. + */ + private static BigDecimal validateBalanced(final List lines) { + if (lines == null || lines.isEmpty()) { + throw new IllegalArgumentException("A voucher needs at least one debit and one credit line."); + } + + BigDecimal debits = BigDecimal.ZERO; + BigDecimal credits = BigDecimal.ZERO; + for (final VoucherLine line : lines) { + if (line.amount() == null || line.amount().signum() <= 0) { + throw new IllegalArgumentException("Every voucher line needs a positive amount; the side is carried by DR/CR."); + } + if (line.isDebit()) { + debits = debits.add(line.amount()); + } else if (ENTRY_CREDIT.equals(line.entryType())) { + credits = credits.add(line.amount()); + } else { + throw new IllegalArgumentException("A voucher line must be DR or CR, not '" + line.entryType() + "'."); + } + } + + if (debits.signum() == 0 || credits.signum() == 0) { + throw new IllegalArgumentException("A voucher needs both a debit and a credit side."); + } + if (debits.compareTo(credits) != 0) { + throw new IllegalArgumentException( + "Voucher does not balance: debits " + debits.toPlainString() + " against credits " + credits.toPlainString() + "."); + } + return debits; + } + + /** Writes the sides of an already-created voucher, numbered in the order given. */ + private void writeLines(final String voucherNo, final List lines) { + int lineNo = 1; + for (final VoucherLine line : lines) { + jdbcTemplate.update(""" + INSERT INTO m_voucher_line + (voucher_no, line_no, entry_type, gl_account_id, savings_account_id, loan_account_id, + party_name, account_no, amount, description) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, + voucherNo, lineNo++, line.entryType(), line.glAccountId(), line.savingsAccountId(), + line.loanAccountId(), line.partyName(), line.accountNo(), line.amount(), line.description()); + } + } + + /** + * Attaches sides to a voucher that was already raised without them. + * + * The three cash registers create their voucher when the request is made, but only know the + * accounts involved once it is approved. Rather than hold the voucher number back until then, + * they raise it up front and post the sides here. + */ + public void addLines(final String voucherNo, final List lines) { + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + validateBalanced(lines); + + // Re-posting the sides of a voucher replaces them rather than stacking a second set + // beside the first, so an approval that runs twice cannot double the printed voucher. + jdbcTemplate.update("DELETE FROM m_voucher_line WHERE voucher_no = ?", voucherNo); + writeLines(voucherNo, lines); + } + + /** + * The GL account a savings account's balance is controlled by, as name and code. + * + * A member's deposit does not post against the member directly — it posts against the Savings + * Control account their product maps to. Lives here rather than in each caller because every + * register that touches a savings account needs the same lookup to print its slip. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map savingsControlAccount(final Long savingsAccountId) { + if (savingsAccountId == null) { + return Map.of(); + } + // product_type 2 = savings, financial_account_type 2 = Savings Control. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 + LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(Map.of()); + } + + /** + * The GL account a savings product charges its interest expense to, as name and code. + * + * The society's side of an interest posting: the member's balance is credited, and this is what + * the credit costs the society. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map savingsInterestExpenseAccount(final Long savingsAccountId) { + if (savingsAccountId == null) { + return Map.of(); + } + // product_type 2 = savings, financial_account_type 3 = Interest on Savings. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 3 + LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(Map.of()); + } + + /** + * The GL account a loan's outstanding balance is carried in, as name and code. + * + * The loan counterpart of {@link #savingsControlAccount}: a disbursement or repayment posts + * against the product's Loan Portfolio account, not against the borrower directly. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map loanPortfolioAccount(final Long loanId) { + if (loanId == null) { + return Map.of(); + } + // product_type 1 = loan, financial_account_type 2 = Loan Portfolio. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_loan l + JOIN acc_product_mapping apm ON apm.product_id = l.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE l.id = ? AND apm.product_type = 1 AND apm.financial_account_type = 2 + LIMIT 1 + """, loanId).stream().findFirst().orElse(Map.of()); + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + public Map glAccount(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT id, name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + + /** + * The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or + * one whose accounts were never recorded — callers show what they have rather than treating an + * older voucher as broken. + */ + public List> findLines(final String voucherNo) { + return jdbcTemplate.queryForList(""" + SELECT line_no, entry_type, gl_account_id, savings_account_id, loan_account_id, + party_name, account_no, amount, description + FROM m_voucher_line + WHERE voucher_no = ? + ORDER BY line_no + """, voucherNo); + } + /** * Looks up the voucher for the given request and returns its voucher_no, * or null if none exists (e.g. records created before vouchers existed). diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index e39a6cdd6..70fbe79ba 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 f51d9bc71..66e5c3fee 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 @@ -1,4 +1,4 @@ - + @@ -574,4 +574,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index ed25176f2..e4b93b87f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -42,4 +42,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml new file mode 100644 index 000000000..8ec2c7378 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml @@ -0,0 +1,63 @@ + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVFORF01' + + + SELECT COUNT(*) FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = 'DIVFORF01' + + + + + + DELETE FROM acc_gl_financial_activity_account + WHERE gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = 'DIVFORF01') + + + DELETE FROM acc_gl_account WHERE gl_code = 'DIVFORF01' + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml new file mode 100644 index 000000000..5227695eb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + UPDATE m_voucher + SET branch_office_id = CAST(SPLIT_PART(voucher_no, '/', 2) AS BIGINT) + WHERE branch_office_id IS NULL + AND voucher_no ~ '^[A-Z]{2,4}/[0-9]{3}/[0-9]{4}/[0-9]{4}$' + + + + + + + + + + + + + + + DELETE FROM m_voucher_sequence s + WHERE EXISTS ( + SELECT 1 FROM m_voucher_sequence t + WHERE t.prefix = s.prefix + AND t.series_date = s.series_date + AND (t.last_number > s.last_number + OR (t.last_number = s.last_number AND t.id < s.id)) + ) + + UPDATE m_voucher_sequence SET branch_code = '' WHERE branch_code IS NOT NULL + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml new file mode 100644 index 000000000..35355a9b5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml new file mode 100644 index 000000000..ef008b32d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml new file mode 100644 index 000000000..ae1d83229 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_share_product_dividend_pay_out WHERE rate_percentage IS NOT NULL + + + + UPDATE m_share_product_dividend_pay_out d + SET rate_percentage = ROUND(d.amount / c.weighted_capital * 100, 4) + FROM ( + SELECT p.id, + COALESCE(SUM(t.amount * ((p.dividend_period_end_date + - GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1)), 0) / 365.0 + AS weighted_capital + FROM m_share_product_dividend_pay_out p + LEFT JOIN m_share_account sa ON sa.product_id = p.product_id AND sa.status_enum = 300 + LEFT JOIN m_share_account_transactions t ON t.account_id = sa.id + AND t.type_enum = 500 AND t.is_active = true AND t.status_enum = 300 + AND t.transaction_date <= p.dividend_period_end_date + AND ((p.dividend_period_end_date + - GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1) >= 365 + GROUP BY p.id + ) c + WHERE c.id = d.id AND c.weighted_capital > 0 AND d.rate_percentage IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml new file mode 100644 index 000000000..d2a41a756 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml @@ -0,0 +1,97 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200304' + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200305' + + + + + + + + + + + + + + + + + + + UPDATE acc_gl_account + SET hierarchy = COALESCE((SELECT p.hierarchy FROM acc_gl_account p WHERE p.gl_code = '200300'), '.') || id || '.' + WHERE gl_code IN ('200304', '200305') + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml new file mode 100644 index 000000000..ab7290a59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml new file mode 100644 index 000000000..33482f5f2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_savings_interest_posting_schedule + (savings_product_id, posting_period_enum, run_at_time, is_active) + SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true + FROM m_savings_product sp + WHERE sp.deposit_type_enum = 100 + AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s + WHERE s.savings_product_id = sp.id) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml new file mode 100644 index 000000000..55a4ce667 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml @@ -0,0 +1,64 @@ + + + + + + + + + SELECT COUNT(*) FROM c_configuration + WHERE name = 'savings-interest-posting-current-period-end' AND enabled = false + + + + + + name = 'savings-interest-posting-current-period-end' + + + +