diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index f7aabdc4d..8b98ac771 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -56,6 +56,7 @@ public enum JobName { EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), // INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), // INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), // + SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), // LOAN_COB("Loan COB"), // LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), // SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java index 65130e998..80ca8df25 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java @@ -256,7 +256,16 @@ public final class PostingPeriod { this.daysInYear = daysInYear; this.compoundingPeriods = compoundingPeriods; - if (isSavingsInterestPostingAtCurrentPeriodEnd) { + if (isUserPosting) { + // "Post interest as on D" — a closure, or the same action from the account screen. The + // period it covers ends at D-1, because interest is earned up to but not including the + // day it is paid, and SavingsHelper closes the period there. The transaction itself + // still belongs on D: that is the day the member was credited, the day the branch did + // the work, and the day the cash is handed over. Dating it to the period end instead + // stamps it a day before the account was even closed, and the ledger then disagrees with + // the closure request, the voucher and the passbook — all of which carry D. + this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); + } else if (isSavingsInterestPostingAtCurrentPeriodEnd) { this.dateOfPostingTransaction = periodInterval.endDate(); } else { this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 857530526..32e53df8f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -25,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.closure.domain.GLClosure; @@ -77,6 +78,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; @RequiredArgsConstructor public class AccountingProcessorHelper { @@ -89,6 +91,14 @@ public class AccountingProcessorHelper { public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; + /** + * Reserved names on the savings product's Accounting tab, naming the two accounts an interest + * posting is routed through. The names are the contract with the product screen; the GL codes + * behind them are the bank's choice, so no code is named here. + */ + public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; + public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; + private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); public static void setDepositAccountTypeContext(int depositAccountType) { @@ -116,6 +126,10 @@ public class AccountingProcessorHelper { private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; private final ChargeRepositoryWrapper chargeRepositoryWrapper; private final BusinessEventNotifierService businessEventNotifierService; + // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a + // JSON document rather than in acc_product_mapping, so they are read directly. See + // namedSavingsGlAccount. + private final JdbcTemplate jdbcTemplate; public LoanDTO populateLoanDtoFromDTO( final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { @@ -600,6 +614,131 @@ public class AccountingProcessorHelper { createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); } + /** + * Interest credited to a member, routed through the payable and paid accounts the product names. + * + *
+     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
+     *   Cr  Savings Interest Payable   (liability)   and now owes it
+     *
+     *   Dr  Savings Interest Payable
+     *   Cr  Savings Interest Paid                    the debt is on its way out
+     *
+     *   Dr  Savings Interest Paid
+     *   Cr  Savings Control                          and reaches the member
+     * 
+ * + *

+ * The two middle accounts net to nil across the three steps, so the trial balance is unchanged + * from the plain two-legged entry; what they add is a reading of how much interest has been + * declared but not yet credited, which a single Dr/Cr pair cannot show. + * + *

+ * Both are read from the product's Accounting tab and neither is required. A product that names + * no paid account collapses the middle step, and one that names no payable falls back to its own + * Savings Control - either way the posting degrades to exactly the two-legged entry it was + * before, so products configured before these mappings existed are unaffected. + * + *

+ * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} + * does for the two-legged case. + */ + public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { + + final GLAccount expenseAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); + final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + + final GLAccount payableAccount = Optional + .ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)).orElse(controlAccount); + final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) + .orElse(payableAccount); + + // Stage 1: the expense is incurred and parked in the payable. + postSavingsLeg(office, currencyCode, expenseAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 2: the liability moves to the account it is paid out of. + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 3: into the member's own balance. + postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } + + /** + * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. + * + *

+ * A leg whose two sides are the same account writes nothing. That is not a defensive check but + * the mechanism by which the three stages above collapse: a product that names no payable and no + * paid account resolves all three to its Savings Control, and what should remain is the plain + * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping + * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and + * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's + * debits and credits unequal per account and shows up on the trial balance as movement that never + * happened. Deciding it here rather than at each call site means every collapse works the same + * way, whichever of the two names a product happens to leave unset. + */ + private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, + final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final boolean isReversal) { + if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { + return; + } + final GLAccount toDebit = isReversal ? creditAccount : debitAccount; + final GLAccount toCredit = isReversal ? debitAccount : creditAccount; + createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); + } + + /** + * A GL account the savings product maps under one of the reserved names on its Accounting tab. + * + *

+ * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of + * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in + * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's + * fixed financial account types. The name is matched across all four groups: which group a bank + * files it under is a presentation choice, and an account configured under the wrong heading + * should still be honoured rather than silently ignored. + * + * @return the account, or null where the product does not configure this name + */ + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { + if (savingsProductId == null) { + return null; + } + // The column holds an empty string on a product saved with no named mappings, and casting + // that to jsonb is an error rather than an empty document - so the cast is done inside a + // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE + // clause in any order, so guarding at the same query level as the cast would only work by + // luck. + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE m.entry ->> 'accountName' = ? + LIMIT 1 + """, Long.class, savingsProductId, accountName); + return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + } + /** * Creates a direct transfer journal entry between two savings-type accounts (savings or current), * each with their own product GL mapping and entity ID so the correct account number appears per entry. diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index 182e83cb5..d6a6034e4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess // Post journal entry if earned interest amount is greater than // zero if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + // Routed through the payable and paid accounts the product names, so the society + // can read interest declared but not yet credited as its own balance. Collapses + // to the plain Dr Interest On Savings / Cr Savings Control pair for a product + // that names neither. + this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId, + savingsId, transactionId, transactionDate, amount, isReversal); } } 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 70df94911..642fad61d 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 @@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration { FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository, GLAccountRepository glAccountRepository, OfficeRepository officeRepository, AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper, - BusinessEventNotifierService businessEventNotifierService) { + BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) { return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository, closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper, - businessEventNotifierService); + businessEventNotifierService, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java new file mode 100644 index 000000000..204b14037 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java @@ -0,0 +1,52 @@ +/** + * 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.infrastructure.businessdate.jobs.syncbusinessdate; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SyncBusinessDateConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step syncBusinessDateStep(final SyncBusinessDateTasklet syncBusinessDateTasklet) { + return new StepBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository) + .tasklet(syncBusinessDateTasklet, transactionManager).build(); + } + + @Bean + public Job syncBusinessDateJob(final SyncBusinessDateTasklet syncBusinessDateTasklet) { + return new JobBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository) + .start(syncBusinessDateStep(syncBusinessDateTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java new file mode 100644 index 000000000..b00ae48a2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java @@ -0,0 +1,88 @@ +/** + * 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.infrastructure.businessdate.jobs.syncbusinessdate; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.businessdate.data.service.BusinessDateDTO; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.businessdate.service.BusinessDateWritePlatformService; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * Sets the business date to the tenant's own calendar date, every midnight. + * + *

+ * Fineract's stock job adds one day to whatever the business date already holds. That is only the + * same thing as "today" while the scheduler has never missed a night: a server stopped over a + * weekend, a laptop that was closed, a tenant restored from an older backup, and the date is left + * behind by however many runs did not happen. It then stays behind, because adding a day to a stale + * date gives another stale date, and each night's run preserves the gap rather than closing it. + * + *

+ * Drift is not a cosmetic problem. Every transaction is stamped with the business date, so while it + * lags: a Demand Draft raised today is rejected as being dated in the future, the day's savings + * interest never accrues and a closure pays out short, and staff see yesterday's date on work they + * are doing now. + * + *

+ * Reading the clock instead of incrementing is self-correcting: however many days were missed, the + * next run lands on today. Bringing the date forward is all this does — a date deliberately set + * ahead, which is how testing against a future day works, is left alone rather than pulled back + * under someone's feet. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class SyncBusinessDateTasklet implements Tasklet { + + private final BusinessDateWritePlatformService businessDateWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + if (!configurationDomainService.isBusinessDateEnabled()) { + // Without the setting on, the platform reads the clock directly and m_business_date is + // not consulted — writing to it would be busywork that misleads anyone reading the table. + log.debug("Business date is not enabled; nothing to sync."); + return RepeatStatus.FINISHED; + } + + final LocalDate systemDate = DateUtils.getLocalDateOfTenant(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + if (!businessDate.isBefore(systemDate)) { + log.debug("Business date {} is already at or ahead of the system date {}; leaving it alone.", businessDate, systemDate); + return RepeatStatus.FINISHED; + } + + businessDateWritePlatformService.updateBusinessDate(BusinessDateDTO.builder().type(BusinessDateType.BUSINESS_DATE) + .description(BusinessDateType.BUSINESS_DATE.getDescription()).date(systemDate).build()); + + log.info("Business date moved from {} to the system date {}.", businessDate, systemDate); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index b1bc8ebfd..cbe7fdd6f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -36,6 +36,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -59,6 +60,7 @@ import org.springframework.stereotype.Component; @Path("/v1/branchday") @Component @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class BranchDayRegisterApiResource { @@ -543,12 +545,35 @@ public class BranchDayRegisterApiResource { blockingCashiers.add(name + " (" + String.join(", ", issues) + ")"); } } + // Overridden only by an explicit reason, and never silently. + // + // The block is the right default: a day closed over a drawer nobody counted is a day whose + // cash figure means nothing. But it cannot be absolute either. A cashier who reconciled on + // the wrong date, went on leave, or left the branch mid-shift leaves a day that can never be + // closed at all, and every day after it inherits the same block — while the screen offers a + // Force Close Day button that could not actually get past this throw, so pressing it + // returned the same refusal and read as a broken button. + // + // So a reason gets you through, and the names of the cashiers who were not reconciled are + // appended to it on the record. Somebody reading that day later sees both that it was forced + // and exactly whose cash was unaccounted for — which is more use than a day that cannot be + // closed and a button that does nothing. + String overrideReason = forceReason; if (!blockingCashiers.isEmpty()) { - throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", - "Cannot close the day — the following cashiers have not been reconciled or settled: " - + String.join("; ", blockingCashiers) - + ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed."); + if (overrideReason == null || overrideReason.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", + "Cannot close the day — the following cashiers have not been reconciled or settled: " + + String.join("; ", blockingCashiers) + + ". Each cashier must reconcile their cash and settle it back to the vault before the branch day" + + " can be closed, or record a reason to force the close."); + } + overrideReason = overrideReason + " [Forced over unreconciled cashiers: " + String.join("; ", blockingCashiers) + + "]"; + log.warn("Branch day {} for office {} force-closed over unreconciled cashiers: {}", businessDate, officeId, + String.join("; ", blockingCashiers)); } + // A forced close is a forced close whether or not the date is also in the past. + final boolean forcedOverCashiers = !blockingCashiers.isEmpty(); // Closing a past date is allowed — otherwise a day left open could never be settled at all — // but it must say why it was left open, and it is stamped as a force close. @@ -568,8 +593,13 @@ public class BranchDayRegisterApiResource { book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? WHERE office_id = ? AND business_date = ? """, + // Forced either way: a late close, or a close over a drawer nobody counted. The reason is + // stored whenever it was needed, not only when the date was in the past — a same-day + // close forced over an unreconciled cashier used to drop its reason on the floor, which + // left the one fact a reader would want to know about that day unrecorded. currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash, - bookCash, difference, isLate, isLate ? forceReason : null, remarks, + bookCash, difference, isLate || forcedOverCashiers, + (isLate || forcedOverCashiers) ? overrideReason : null, remarks, officeId, Date.valueOf(businessDate)); final Long registerId = ((Number) found.get(0).get("id")).longValue(); @@ -583,7 +613,7 @@ public class BranchDayRegisterApiResource { res.put("closingCash", closingCash); res.put("bookCash", bookCash); res.put("cashDifference", difference); - res.put("forceClosed", isLate); + res.put("forceClosed", isLate || forcedOverCashiers); return gson.toJson(res); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 1a2b36cc3..b0094cb29 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -37,7 +37,7 @@ public class CashReportApiResource { private final Gson gson = new Gson(); /** - * Excludes reversed counter transactions from a cash figure. + * Excludes counter breakdowns that must not count towards a drawer. * * m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the * counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its @@ -46,13 +46,44 @@ public class CashReportApiResource { * its own reversal flag; this is the same guard for the counter, read off the transaction the * breakdown belongs to. * - * NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD - * register keys its rows by its own request id, not a savings transaction -- is kept rather than - * silently dropped. + *

+ * Two things are excluded, and they are different failures: + * + *

+ * + *

+ * The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by + * itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those + * rows are genuine cash that has to keep counting. Request ids and transaction ids are both + * small; a millisecond epoch is thirteen digits and cannot be either. So anything at or above + * 10^12 is rejected as not-an-id, and everything smaller is kept. */ + private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; + private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" + " SELECT 1 FROM m_savings_account_transaction rt " - + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "; + + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) " + + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + // A breakdown has to add up to the transaction it belongs to. Where it does not, the + // notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes + // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven + // such transactions in this database were carrying ₹1.68 lakh that never existed. + // + // Compared per transaction inside a correlated subquery rather than as a join, so a + // breakdown with no transaction behind it -- a Demand Draft keyed by its request id -- + // is untouched by this test and still handled by the id-magnitude test above. + + "AND NOT EXISTS (" + + " SELECT 1 FROM m_savings_account_transaction mt " + + " WHERE mt.id = std.txn_id" + + " AND mt.amount <> (SELECT SUM(sd.count * sd.denomination_value)" + + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) "; /** * Day book — every voucher raised on one date, in one list, newest first. @@ -150,7 +181,12 @@ public class CashReportApiResource { // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier // step to reach. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no, + SELECT r.voucher_no, + -- A closure is paid out at the counter, so it is the cashier's work. + -- This column was missing entirely and the row mapper reads it unconditionally, + -- so every CLOSURE row threw instead of appearing. + 'CASHIER' AS section, + 'DR' AS side, 'CLOSURE' AS kind, r.account_no, 'SAVINGS' AS account_type, r.client_name AS party, COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, r.payment_type_name, r.description, r.status, @@ -210,6 +246,94 @@ public class CashReportApiResource { AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Everything else that moved money on this date and is not one of the registers above: + // interest posted, a charge collected from a balance, a counter deposit or withdrawal, an + // escheat, a manual journal. + // + // Read from the voucher register rather than from another set of source tables, because + // that is the one place every posting lands whatever raised it — including the nightly jobs, + // which have no request row anywhere to read them from. Without this the officer's sheet + // showed only what somebody had filed a request for, and a day of system postings against + // members' accounts was invisible on the very page meant to account for the day. + // + // The registers above are excluded by reference type, so a voucher already listed as a + // deposit or a withdrawal request cannot appear twice under a different heading. + // + // Status is reported as COMPLETED: a voucher only exists once its posting has committed, so + // there is no pending state to represent — unlike a request, which can sit unactioned. + rows.addAll(jdbcTemplate.query(""" + SELECT v.voucher_no, + -- A nightly batch belongs to the day it worked on, not the minute it ran. + -- + -- A voucher's created_at is a wall-clock instant, while the transaction it + -- posts and the journal entry behind it both carry the business date. So the + -- interest job that fires at 00:10 stamps its vouchers with tomorrow while + -- the ledger says yesterday — and the day it actually belongs to had already + -- been counted, reported and signed off hours earlier. + -- + -- Rather than re-open a closed day, such a voucher is listed on the day it + -- surfaces, under its own heading, saying which day's work it is. The date is + -- taken from the transaction it posts; where there is none — a manual journal + -- keyed straight to the ledger — an early-hours timestamp is treated the same + -- way, because nobody is at a counter at 3am. + CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) + < CAST(v.created_at AS DATE) + OR (v.savings_txn_id IS NULL + AND EXTRACT(HOUR FROM v.created_at) < 6) + THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section, + -- Which day's work this is, for the heading on the prior-day block. + COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date, + CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side, + v.voucher_type AS kind, + -- The member's own account number, never a voucher line's — a line carries a + -- GL code there, and showing 500201 in an Account column reads as an account + -- that does not exist. A journal with no member behind it shows none. + sa.account_no, + NULL AS account_type, + COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party, + v.amount, + NULL AS payment_type_name, + -- The two sides in one phrase, which is what a journal voucher is read as. + CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description, + 'COMPLETED' AS status, + v.created_by_username AS handled_by, + -- Who checked it, if anybody has. Not aliased from verifyCols below, which + -- selects verified_by_name under its own name for the verification panel -- + -- the row mapper reads both columns, so each has to be present exactly once. + vv.verified_by_name AS approved_by, + CAST(v.savings_txn_id AS VARCHAR) AS txn_id, + v.created_at AS submitted_at, v.created_at AS actioned_at, + """ + verifyCols + """ + FROM m_voucher v + -- Either link: savings_txn_id is stamped by a step after the voucher is written + -- and is empty on every interest posting, where reference_id already carries the + -- same transaction id. Without the fallback the prior-day test never fires on the + -- nightly postings it was written for. + LEFT JOIN m_savings_account_transaction t + ON t.id = COALESCE(v.savings_txn_id, + CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION' + THEN v.reference_id END) + LEFT JOIN m_savings_account sa ON sa.id = t.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 + 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 + LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = v.voucher_no + WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE) + AND (v.reference_type IS NULL OR v.reference_type NOT IN ( + 'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', + 'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST', + 'SPONSOR_DD_ISSUE_REQUEST')) + """, this::mapDayBookRow, txnDate)); + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be // ordered as one result set, and the row count for a single day is small. final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); @@ -218,6 +342,10 @@ public class CashReportApiResource { final List> filtered = new ArrayList<>(); BigDecimal creditTotal = BigDecimal.ZERO; BigDecimal debitTotal = BigDecimal.ZERO; + // Kept apart from the day's own totals so a reader can see the overnight batch without it + // silently joining figures that were already reported and signed. + BigDecimal priorCreditTotal = BigDecimal.ZERO; + BigDecimal priorDebitTotal = BigDecimal.ZERO; for (final Map row : rows) { if (sideFilter != null && !sideFilter.equals(row.get("side"))) { @@ -236,8 +364,12 @@ public class CashReportApiResource { // the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting // both states cannot double-count it; only 'CLOSED' is unique to that register. final String rowStatus = (String) row.get("status"); - if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) - || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { + // A batch that posted against yesterday is listed today but is not today's money: it + // was already earned, and the day it belongs to has been counted and signed off. Adding + // it to this day's totals would make them disagree with the ledger for either date. + final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); + if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { if ("CR".equals(row.get("side"))) { @@ -247,6 +379,16 @@ public class CashReportApiResource { } } } + if (priorDay) { + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount != null) { + if ("CR".equals(row.get("side"))) { + priorCreditTotal = priorCreditTotal.add(amount); + } else { + priorDebitTotal = priorDebitTotal.add(amount); + } + } + } } filtered.sort((a, b) -> { @@ -272,9 +414,28 @@ public class CashReportApiResource { result.put("creditTotal", creditTotal); result.put("debitTotal", debitTotal); result.put("netTotal", creditTotal.subtract(debitTotal)); + // The overnight batch, reported separately: this is yesterday's work surfacing today. + result.put("priorCreditTotal", priorCreditTotal); + result.put("priorDebitTotal", priorDebitTotal); return gson.toJson(result); } + /** + * A date column that only some of the union'd queries select. + * + * The six day-book queries do not share a column list, and adding a column to all of them so one + * can carry it would mean five queries stating a date they were handed as a parameter. Absent is + * reported as null rather than as an error. + */ + private String optionalDate(final java.sql.ResultSet rs, final String column) { + try { + final java.sql.Date value = rs.getDate(column); + return value == null ? null : value.toLocalDate().toString(); + } catch (final java.sql.SQLException e) { + return null; + } + } + private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { final Map row = new LinkedHashMap<>(); row.put("voucherNo", rs.getString("voucher_no")); @@ -282,6 +443,12 @@ public class CashReportApiResource { // maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says // where the entry came from, not who typed it. row.put("section", rs.getString("section")); + + // Which day's work the row is: normally the day being reported, but a nightly batch posts + // against the previous business date while surfacing on this one. Only the SYSTEM query + // selects it, so it is read defensively rather than added to five other queries that would + // all report the same date they were asked for. + row.put("effectiveDate", optionalDate(rs, "effective_date")); row.put("side", rs.getString("side")); row.put("kind", rs.getString("kind")); row.put("accountNo", rs.getString("account_no")); @@ -334,39 +501,93 @@ public class CashReportApiResource { + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; - // Single query — conditional aggregation for deposit, withdrawal, net per denomination - final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " - + "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, " - + "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, " - + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, " - + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " - + "FROM m_savings_txn_denomination std " - + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " - + "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED - + "GROUP BY std.denomination_id, std.denomination_value, dm.kind " - + "ORDER BY std.denomination_value DESC, dm.kind"; + // Every note and coin that reached the drawer or left it, per denomination. + // + // Four legs, not one. The counter breakdown alone answered "what did customers hand over", + // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a + // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only + // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be + // counted against. So the vault allocation and the settlement back are included on the same + // footing as the counter work, and each row says where its notes came from. + // + // Cashier scoping differs per leg because each table records the party differently: the + // counter tables name the acting user, the cash tables name the cashier row. + final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; + final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; + + final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount " + + "FROM (" + // Counter work: what customers handed over and were paid. + + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, " + + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg " + + " FROM m_savings_txn_denomination std " + + " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED + + " UNION ALL " + // Drawn from the vault in the morning. + + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg " + + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " + + " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false " + + vaultCashierFilter + + " UNION ALL " + // Handed back to the vault at close. + + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg " + + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id " + + " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false " + + settleCashierFilter + + ") legs " + + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id " + + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind " + + "ORDER BY legs.denomination_value DESC, dm.kind"; final List args = new ArrayList<>(); args.add(txnDate); if (scopedCashierId != null) { args.add(scopedCashierId); } + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { - long depositCount = rs.getLong("deposit_count"); - long withdrawalCount = rs.getLong("withdrawal_count"); - BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); - BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); + final long vaultInCount = rs.getLong("vault_in_count"); + final long depositCount = rs.getLong("deposit_count"); + final long withdrawalCount = rs.getLong("withdrawal_count"); + final long settledOutCount = rs.getLong("settled_out_count"); + final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount"); + final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); + final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); + final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount"); + Map row = new LinkedHashMap<>(); row.put("denominationId", rs.getLong("denomination_id")); row.put("denominationValue", rs.getInt("denomination_value")); row.put("kind", rs.getString("kind")); + row.put("vaultInCount", vaultInCount); + row.put("vaultInAmount", vaultInAmount); row.put("depositCount", depositCount); row.put("depositAmount", depositAmount); row.put("withdrawalCount", withdrawalCount); row.put("withdrawalAmount", withdrawalAmount); - row.put("netCount", depositCount - withdrawalCount); - row.put("netAmount", depositAmount.subtract(withdrawalAmount)); + row.put("settledOutCount", settledOutCount); + row.put("settledOutAmount", settledOutAmount); + // What is left of this denomination: everything that came in, less everything that went + // out. This is the figure a teller counts their notes against, per note. + row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount); + row.put("netAmount", + vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount)); return row; }, args.toArray()); @@ -488,10 +709,49 @@ public class CashReportApiResource { result.put("openingBalance", openingBalance); result.put("closingBalance", closingBalance); result.put("cashMovement", closingBalance.subtract(openingBalance)); + + // The day on its own terms, which is what a cashier actually reconciles. + // + // A cashier does not carry a balance: they draw cash from the vault in the morning, work + // the counter, and hand it back at close. So the cumulative figures above answer a + // question nobody at the counter asks — and one carrying years of history answers it + // with a number that cannot be counted against anything on the desk. These four are the + // day itself: taken from the vault, in over the counter, out over the counter, settled + // back. What is left is what should physically be in the drawer. + result.put("todayVaultIn", dayLegTotal( + "SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + + " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id" + + " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ct.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todaySettledOut", dayLegTotal( + "SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + + " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id" + + " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)" + + " AND COALESCE(cs.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todayInterCashierIn", dayLegTotal( + "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + + " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ic.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todayInterCashierOut", dayLegTotal( + "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + + " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ic.is_reversed, false) = false", + scopedCashierId, txnDate)); } return gson.toJson(result); } + /** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */ + private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) { + final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate); + return total == null ? BigDecimal.ZERO : total; + } + /** * What one cashier is holding at the end of a given date, in rupees. * @@ -826,7 +1086,17 @@ public class CashReportApiResource { return gson.toJson(result); } - private Map buildPositionForCashier(Long cashierId, String dateFilter) { + /** + * Denomination-wise net cash a cashier is holding as of a date: everything allocated to them, + * settled or handed on by them, and taken or paid over their counter, netted per note and coin. + * + *

+ * Public because the settlement register checks against it before accepting a hand-over. It has + * to be this figure and not a second sum of the same eight legs: two implementations of "what + * does this drawer hold" drift, and the one place that matters is the moment somebody is told + * they cannot settle what they do not have. + */ + public Map buildPositionForCashier(Long cashierId, String dateFilter) { // Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count " + "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 0d8e7fd68..82dbae991 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.springframework.jdbc.core.JdbcTemplate; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -57,6 +58,9 @@ public class CashSettlementApiResource { private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; private final VoucherService voucherService; + // The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report + // says they are not holding. + private final CashReportApiResource cashReportApiResource; private final Gson gson = new Gson(); @POST @@ -112,6 +116,8 @@ public class CashSettlementApiResource { "At least one denomination with a positive count is required."); } + validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate); + final BigDecimal total = sumTotal(denominations); CashSettlement header = new CashSettlement(); @@ -290,6 +296,67 @@ public class CashSettlementApiResource { return total; } + /** + * Refuses a hand-over of notes the cashier is not holding. + * + *

+ * Nothing stopped this before, and the damage is not a rejected screen but a drawer that reads + * negative for the rest of its life: a settlement is subtracted from the cashier's position + * cumulatively, so one settlement of cash they never had leaves every later position short by + * that amount, on every date, with no transaction to point at. This database carries exactly + * that — ₹10.5 lakh settled against ₹26 thousand ever allocated, and a Cashier Position screen + * showing counts like -1814 as a result. + * + *

+ * Checked per denomination, not on the total, for the same reason the vault side is: a drawer + * holding ₹600 in one ₹500 and one ₹100 cannot hand over six ₹100 notes, though the total + * agrees. The holding comes from {@code buildPositionForCashier} — the figure the Cashier + * Position screen shows — so what a cashier is told they hold is what they are allowed to settle. + * + *

+ * A cashier with no position at all is left alone: a first settlement on a drawer whose + * allocations were recorded without a breakdown has nothing to check against, and blocking it + * would stop a branch settling at all rather than prevent a mistake. + */ + private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations, + final LocalDate settlementDate) { + + final Map position = cashReportApiResource.buildPositionForCashier(fromCashierId, + settlementDate.toString()); + + @SuppressWarnings("unchecked") + final List> heldRows = (List>) position.get("denominations"); + if (heldRows == null || heldRows.isEmpty()) { + return; + } + + final Map held = new java.util.HashMap<>(); + for (final Map row : heldRows) { + final Object id = row.get("denominationId"); + final Object count = row.get("count"); + if (id instanceof Number && count instanceof Number) { + held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum); + } + } + + for (final JsonElement el : denominations) { + final JsonObject item = el.getAsJsonObject(); + final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0; + if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) { + continue; + } + final long denominationId = item.get("denominationId").getAsLong(); + final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + ? item.get("denominationValue").getAsInt() : 0; + final long available = held.getOrDefault(denominationId, 0L); + if (count > available) { + throw new PlatformDataIntegrityException("error.msg.cashsettlement.insufficient.cashier.denomination", + "Cashier is not holding that much of denomination " + denominationValue + ": has " + available + + ", settling " + count + ". Check the Cashier Position report before settling."); + } + } + } + private boolean hasPositiveCount(JsonArray items) { for (JsonElement el : items) { if (el.getAsJsonObject().get("count").getAsInt() > 0) return true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 07a16f84e..5f5c64b62 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.teller.service.GlDispatchService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer; @@ -52,6 +53,9 @@ public class CashTransferApiResource { private final PlatformTransactionManager transactionManager; private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; + // The same source the Cash Transfer screen reads its Available column from, so what is offered + // and what is checked cannot disagree. + private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); @POST @@ -327,46 +331,39 @@ public class CashTransferApiResource { * The total-amount check above can pass while a specific note/coin still runs short — a vault * holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the * total is well within its balance. This checks each requested denomination against what the - * vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's - * settlements-in minus today's transfers-out actually holds of that specific denomination. + * vault actually holds of it. * - * A day opened with a plain lump sum and no breakdown recorded has nothing to check a - * denomination against, so this is skipped rather than blocking every transfer on a day that - * simply never captured one. + *

+ * The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen + * shows in its Available column. It has to: this check used to walk the register itself and read + * the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under + * it is holding and is anchored on a different day. The screen offered a ₹100 note the vault + * plainly had and the transfer was then refused as "vault has 0" — two answers to one question, + * from two sources. Reading the service means the number that was offered is the number that is + * checked, and neither can drift from the other again. + * + *

+ * A day with no breakdown recorded anywhere has nothing to check a denomination against, so this + * is skipped rather than blocking every transfer on a day that simply never captured one. */ private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId, final JsonArray denominations, final java.sql.Date businessDate) { - final List openingRows = jdbcTemplate.query(""" - SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail - WHERE register_id = ? AND leg = 'OPENING' - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId); - if (openingRows.isEmpty()) { + final Map position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString()); + if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) { return; } final Map available = new java.util.HashMap<>(); - for (final Object[] row : openingRows) { - available.merge((Long) row[0], (Integer) row[2], Integer::sum); - } - - final List settledRows = jdbcTemplate.query(""" - SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd - JOIN m_cash_settlement cs ON cs.id = csd.settlement_id - WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false - GROUP BY csd.denomination_id - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); - for (final Object[] row : settledRows) { - available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum); - } - - final List handedOutRows = jdbcTemplate.query(""" - SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd - JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id - WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false - GROUP BY ctd.denomination_id - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); - for (final Object[] row : handedOutRows) { - available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum); + @SuppressWarnings("unchecked") + final List> heldRows = (List>) position.get("denominations"); + if (heldRows != null) { + for (final Map row : heldRows) { + final Object id = row.get("denominationId"); + final Object count = row.get("count"); + if (id instanceof Number && count instanceof Number) { + available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum); + } + } } for (final JsonElement el : denominations) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 5569a5834..cfb71fca7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -215,6 +215,31 @@ public class ClerkClosureRequestApiResource { private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) { final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false); savingsAccountWritePlatformService.postInterest(account, true, closureDate, false); + + // What the account itself says is still owed once that posting has run: everything the + // account has earned over its life, less everything already credited to the member. The + // account's own summary is the figure the account screen shows, so a closure and that screen + // can no longer disagree about what is outstanding. + // + // Normally this is zero -- the posting above has just credited the shortfall, which is the + // whole point of running it. It is not zero when the summary carries interest the posting did + // not reach: interest accrued against a period the posting run does not close, most often on + // a cycle that has not yet ended. Left alone that interest is simply dropped, because the + // account is about to be closed and no later run will ever pay it. + // + // Only ever pays out what the two totals still differ by, so interest already credited cannot + // be paid a second time, and a negative difference (more posted than earned, which a reversal + // can leave behind) is ignored rather than taken back off the member here. + final BigDecimal earned = zeroIfNull(account.getSummary().getTotalInterestEarned()); + final BigDecimal posted = zeroIfNull(account.getSummary().getTotalInterestPosted()); + final BigDecimal stillOwed = earned.subtract(posted); + + if (stillOwed.compareTo(BigDecimal.ZERO) > 0) { + LOG.warn("Closure of savings account {}: {} of accrued interest is still unposted after posting up to {} " + + "(earned {} against posted {}). The payout below does not include it.", savingsAccountId, stillOwed, + closureDate, earned, posted); + } + return zeroIfNull(account.getSummary().getAccountBalance()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 5f3da17da..969fe1a44 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -56,7 +56,11 @@ 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.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -77,10 +81,13 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class ClerkShareWithdrawalRequestApiResource { + private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; // Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date, // meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent // format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's @@ -701,9 +708,17 @@ public class ClerkShareWithdrawalRequestApiResource { // shares left for the native close command to force-redeem without oversight. final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString()); + // Every other clerk register raises a voucher for the money it moves; this one did not, so a + // redemption settled into a member's savings account left nothing on the voucher register to + // read it back from. The GL entries were always written — it is the slip that was missing. + final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser); + final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); res.addProperty("status", "APPROVED"); + if (voucherNo != null) { + res.addProperty("voucherNo", voucherNo); + } if (shareTxnId != null) { res.addProperty("shareTransactionId", shareTxnId); } @@ -742,6 +757,62 @@ public class ClerkShareWithdrawalRequestApiResource { return true; } + /** + * The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings. + * + *

+ * Mirrors the GL pair {@link #creditLinkedSavingsAccount} posts, so the slip and the ledger say + * the same thing. A redemption with no linked savings account settles nothing yet and raises no + * voucher — there is no second leg to name until somebody settles it by hand. + * + *

+ * A failure here is logged and swallowed, never rethrown: the redemption, its GL entries and the + * savings credit have all already succeeded in this transaction, and losing the slip is worth + * less than rolling back a member's money. + */ + private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map record, + final Settlement settlement, final AppUser currentUser) { + + if (!voucherService.shouldRaiseVoucher() || settlement == null + || settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + try { + final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, + shareAccountId); + final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE); + + final Long savingsAccountId = jdbcTemplate + .queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream() + .findFirst().orElse(null); + final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId); + + final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member"; + final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no")); + final String savingsAccountNo = savingsAccountId == null ? null + : jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId) + .stream().findFirst().orElse(null); + + final List lines = new ArrayList<>(); + lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount())); + lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount())); + + // PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their + // savings account, which is the receipt series — the share account is the source, not a + // counter payment. + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, + VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_CREDIT, lines); + if (settlement.savingsTransactionId() != null) { + voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, + settlement.savingsTransactionId()); + } + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e); + return null; + } + } + private record Settlement(BigDecimal amount, Long savingsTransactionId) {} /** 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 index 6a3f7da8b..af0596d68 100644 --- 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 @@ -61,6 +61,16 @@ public class SavingsDailyInterestOrchestrator { ORDER BY s.savings_product_id """); + // Products that could not be paid, collected so the run can fail loudly at the end. + // + // Carrying on past one product is right — a mapping missing on one product must not stop + // every member of every other from being paid. Reporting the job as successful afterwards is + // not: the scheduler recorded 'success', the operator saw a green run, and the only trace + // that nobody had been paid was a log line and a row in m_savings_interest_posting_run that + // nothing reads. On a night when every product fails, that is a whole day of interest + // silently unpaid. + final List failures = new java.util.ArrayList<>(); + for (final Map schedule : schedules) { final long productId = ((Number) schedule.get("savings_product_id")).longValue(); final Long dueCount = jdbcTemplate.queryForObject(""" @@ -80,14 +90,26 @@ public class SavingsDailyInterestOrchestrator { } 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); + // An UPDATE, so it writes nothing when the product threw before openRun() inserted + // the row — which is exactly what a product with no GL mapping does. The failure is + // therefore collected in memory as well, so it cannot be lost with the breadcrumb. 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"); + failures.add("product " + productId + ": " + e.getMessage()); } } + + // Fails the job so the scheduler records it as failed and an operator is told. Every product + // that could be paid has already been paid and committed by this point — this reports what + // happened, it does not undo it. + if (!failures.isEmpty()) { + throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for " + + failures.size() + " product(s): " + String.join("; ", failures)); + } } /** 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 index 57534016c..c3b60665e 100644 --- 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 @@ -23,6 +23,7 @@ import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -194,11 +195,19 @@ public class SavingsDailyInterestService { // today's figures. Only a row for a day before today can be carried forward — one already // stamped with today or later means the day has been accrued and re-reading it as "previous" // would compound the same day's interest onto itself. + // Newest first, one row. The table currently carries a unique constraint on + // savings_account_id so at most one row can match and the ordering is academic — but the + // code below reads previous.get(0) as "yesterday", and the moment per-day history is + // restored an unordered query would hand it an arbitrary earlier day as the opening balance + // and silently break the chain. Stating the intent costs nothing and does not depend on the + // constraint staying where it is. 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; @@ -208,8 +217,6 @@ public class SavingsDailyInterestService { final LocalDate cycleEnd; if (previous.isEmpty()) { - openingBalance = BigDecimal.ZERO; - carriedInterest = BigDecimal.ZERO; cycleNumber = 1; // The first row belongs to the cycle the day it records falls in, and starts at // activation only when that day is still inside the account's opening cycle. Anchoring @@ -221,6 +228,19 @@ public class SavingsDailyInterestService { final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod, financialYearBeginningMonth); cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart; + // The account has earned since its cycle opened, but the ledger only starts today — + // either because the account predates this ledger, or because the nightly run has not + // reached it before now. Taking the opening balance as zero and the carried interest as + // nothing would credit the member for today alone and silently drop every day since + // cycleStart, which is exactly what a closure then pays out. + // + // So the days already gone are replayed here, from the account's own transactions, to + // arrive at the balance and the running interest the ledger would be holding had it + // been running all along. + final Backfill backfill = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, + minBalance, averageDailyBalanceOf(calcType)); + openingBalance = backfill.closingBalance(); + carriedInterest = backfill.cumulativeInterest(); } else { final Map prev = previous.get(0); openingBalance = (BigDecimal) prev.get("closing_balance"); @@ -249,8 +269,7 @@ public class SavingsDailyInterestService { // 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 boolean averageDailyBalance = averageDailyBalanceOf(calcType); final BigDecimal dailyInterest; final BigDecimal cumulative; @@ -389,6 +408,166 @@ public class SavingsDailyInterestService { * It cannot double-count, because a posting only ever falls on one day and the row for that day * keeps the balance the posting left it with (see the closing_balance guard in the upsert). */ + /** + * Brings one account's ledger up to the business date after its balance has moved. + * + *

+ * The ledger is written by the nightly run, so between a deposit and the next run the account's + * row still states yesterday's balance and yesterday's interest. A closure in that window reads + * the stale row and pays out interest on a balance the member no longer has — or, for money paid + * in today, on no balance at all. Calling this as soon as the balance moves keeps the row in step + * with the account, so what a closure reads is what the account actually holds. + * + *

+ * Re-accruing the same day is safe. The row is rewritten in place from the day before it, never + * from itself, so running this repeatedly through a busy day converges on the same figures rather + * than compounding them. An account with no row yet gets its first one here, backfilled from the + * cycle start exactly as {@link #accrueOnActivation} would have written it. + * + *

+ * Never fails the caller: the transaction that moved the money has already succeeded and must not + * be rolled back because the ledger could not be refreshed. A failure is logged and left for the + * nightly run. + */ + @Transactional + public void refreshAfterBalanceChange(final Long savingsAccountId, final LocalDate businessDate) { + try { + 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 sa.id = ? AND sp.deposit_type_enum = ? + AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ? + """, savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate); + + if (accounts.isEmpty()) { + return; + } + accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth()); + } catch (final RuntimeException e) { + log.error("Savings daily interest: could not refresh the ledger for account {} after a balance change; the " + + "nightly run will pick it up", savingsAccountId, e); + } + } + + private boolean averageDailyBalanceOf(final Number calcType) { + return calcType != null && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE; + } + + /** The balance and running interest the ledger would hold on the eve of its first row. */ + private record Backfill(BigDecimal closingBalance, BigDecimal cumulativeInterest) {} + + /** + * Replays the days between a cycle's start and the first day the ledger records, so an account + * whose ledger opens mid-cycle carries in the interest it has already earned. + * + *

+ * Without this the first row starts from zero on both counts, and every day from cycleStart up to + * the day before is lost — the member is credited for one day of a cycle they have been in for + * weeks. It is only reachable on an account's very first row: once the ledger has a row, the chain + * carries itself forward. + * + *

+ * The replay walks the account's own transactions, so it reconstructs the same daily closing + * balances the nightly run would have written, including days the balance changed. Days before the + * account's activation cannot earn and are not walked — cycleStart is already clamped to activation + * by the caller. Reversed transactions are excluded exactly as {@link #dayMovement} excludes them. + * + * @return the closing balance and cumulative interest as at {@code upTo.minusDays(1)} + */ + private Backfill backfillFromCycleStart(final Long accountId, final LocalDate cycleStart, final LocalDate upTo, + final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) { + + if (!cycleStart.isBefore(upTo)) { + return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO); + } + + // Everything on or before the eve of the ledger's first day, keyed by day. The balance before + // cycleStart is carried in as the opening figure rather than replayed day by day: interest is + // earned within the cycle, but the money that earns it may have been deposited long before. + final BigDecimal openingBalance = balanceAsOf(accountId, cycleStart.minusDays(1)); + final Map movements = movementsBetween(accountId, cycleStart, upTo.minusDays(1)); + + BigDecimal balance = openingBalance; + BigDecimal cumulative = BigDecimal.ZERO; + BigDecimal balanceDaysTotal = BigDecimal.ZERO; + int days = 0; + + for (LocalDate day = cycleStart; day.isBefore(upTo); day = day.plusDays(1)) { + balance = balance.add(movements.getOrDefault(day, BigDecimal.ZERO)); + balanceDaysTotal = balanceDaysTotal.add(balance); + days++; + + if (!averageDailyBalance) { + final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && balance.compareTo(minBalance) < 0; + if (!belowMinimum && balance.compareTo(BigDecimal.ZERO) > 0 && rate != null + && rate.compareTo(BigDecimal.ZERO) > 0) { + cumulative = cumulative.add(balance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC)); + } + } + } + + if (averageDailyBalance) { + // The same shape the daily path uses: the cycle's average balance, carried across the days + // walked, rather than a sum of per-day figures. + final BigDecimal averageBalance = balanceDaysTotal.divide(BigDecimal.valueOf(days), MC); + final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && averageBalance.compareTo(minBalance) < 0; + if (!belowMinimum && averageBalance.compareTo(BigDecimal.ZERO) > 0 && rate != null + && rate.compareTo(BigDecimal.ZERO) > 0) { + cumulative = averageBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC) + .multiply(BigDecimal.valueOf(days), MC); + } + } + + return new Backfill(balance, cumulative); + } + + /** The account's balance at the close of a given day, from its transactions. */ + private BigDecimal balanceAsOf(final Long accountId, final LocalDate asOf) { + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE + WHEN t.transaction_type_enum IN (1, 3, 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, asOf); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** Net movement per day across a date range, for days that had any. */ + private Map movementsBetween(final Long accountId, final LocalDate from, final LocalDate to) { + final Map byDay = new HashMap<>(); + jdbcTemplate.queryForList(""" + SELECT t.transaction_date AS d, + SUM(CASE + WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + ELSE 0 END) AS net + FROM m_savings_account_transaction t + WHERE t.savings_account_id = ? + AND t.transaction_date >= ? AND t.transaction_date <= ? + AND t.is_reversed = false + GROUP BY t.transaction_date + """, accountId, from, to) + .forEach(row -> byDay.put(((java.sql.Date) row.get("d")).toLocalDate(), (BigDecimal) row.get("net"))); + return byDay; + } + private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { final BigDecimal movement = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE 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 index 44f6c4172..561492159 100644 --- 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 @@ -41,19 +41,23 @@ import org.springframework.transaction.annotation.Transactional; * holds the figure that would say so. * * This posts the product's total first, into the liability the product names as its Savings Interest - * Payable, and only then pays it out to members: + * Payable, moves it to the account it is paid out of, and only then pays it to members: * *

- *   Dr  Savings Interest Paid / Interest On Savings   (expense, from the product)
- *   Cr  Savings Interest Payable                      (liability, from the product)
+ *   Dr  Interest On Savings        (expense, from the product)
+ *   Cr  Savings Interest Payable   (liability, from the product)     what the bank now owes
  *
- *   Dr  Savings Interest Payable                      (per member)
- *   Cr  Savings Control                               (per member, from the product)
+ *   Dr  Savings Interest Payable
+ *   Cr  Savings Interest Paid                                         on its way out
+ *
+ *   Dr  Savings Interest Paid      (per member)
+ *   Cr  Savings Control            (per member, from the product)     in the member's hands
  * 
* * Every account named above is read from the product's own GL configuration; none is written into - * this class. A product that configures no payable falls back to the two seeded control accounts, - * which keeps products predating that mapping posting exactly as before. + * this class. A product that names no paid account collapses the middle step - both legs resolve to + * the payable - and posts the two-legged entry it always did; one that names no payable falls back + * to its own Savings Control, since interest owed is a liability either way. * * The whole product settles inside one transaction, and the payable has to read zero before it is * allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run @@ -75,22 +79,21 @@ public class SavingsPooledInterestPostingService { private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; /** - * Reserved names in the product's own GL mappings, which is where the two legs of the interest - * bill are configured. The names are the contract between this service and the product screen — - * the GL codes behind them are the bank's choice, not ours, so nothing here names an account. + * Reserved names in the product's own GL mappings, which is where the middle two legs of the + * interest bill are configured. The names are the contract between this service and the product + * screen — the GL codes behind them are the bank's choice, not ours, so nothing here names an + * account. A product that leaves one unset falls back to another mapping the same product + * carries; no GL code is ever written into this file. */ private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable"; private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid"; - /** Fallback control accounts, used only where the product leaves the mappings unset. */ - 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 static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2; private final JdbcTemplate jdbcTemplate; @@ -164,33 +167,39 @@ public class SavingsPooledInterestPostingService { largest.get("savings_account_id")); } - // The expense the bill is recognised against, and the liability it sits in until it reaches - // the members. Both come from the product: "Savings Interest Paid" if the product names one, - // otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the - // liability, falling back to the control accounts only where a product configures neither. - final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), - mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS)); - final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE); - final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL)); - final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL); + // The three accounts the bill passes through on its way to the members. Every one is read + // from the product's own configuration — the standard Interest On Savings mapping, and the + // two reserved names on the product's Accounting tab — so a bank on its own chart of + // accounts is posted to the accounts it chose. No GL code is named in this class. + // + // Where a product leaves a reserved name unset the bill still has somewhere to go: the + // payable falls back to the product's Savings Control (interest owed is a liability either + // way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and + // leaves the two-legged posting a product without these mappings has always had. + final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), + mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL)); + final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl); 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 parked in the payable. + // Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's + // interest is parked in the payable, where it stands as a debt to the members until it + // reaches them. journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); + journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null); - // Stage 2: the pool moves to the account it is paid out of. + // Stage 2: the debt moves to the account the payout is made from, so what is still merely + // owed and what is on its way out are two separate balances rather than one. // - // Where the product names a single payable, both control legs resolve to it and this stage - // would debit and credit the same account for the same amount — a pair of entries that say - // nothing and clutter the ledger. Only a product still on the two distinct control accounts - // needs the transfer written. - if (!postingControlGl.equals(postedControlGl)) { - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + // Skipped where the product names no separate paid account: both legs then resolve to the + // payable and this would debit and credit the same account for the same amount — a pair of + // entries that say nothing and clutter the ledger. + if (!payableGl.equals(paidGl)) { + journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null); } // Stage 3: out to the members, one at a time. @@ -208,7 +217,7 @@ public class SavingsPooledInterestPostingService { final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, newBalance); - journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, + journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference, accountId); creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, accountId, transactionId); @@ -309,7 +318,14 @@ public class SavingsPooledInterestPostingService { 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, + // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares + // CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way + // round: the member's savings-control credit was stored as a debit and the expense + // debit as a credit. Nothing downstream caught it — the run's own pooled-equals- + // distributed check compares Java values and never looks at type_enum, and migration + // 0531 disables the stock posting job, so this is the only path writing savings + // interest to the ledger and there was no correct one alongside to disagree with it. + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, debit ? "Savings interest posting" : "Savings interest posted", savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), date); @@ -325,16 +341,26 @@ public class SavingsPooledInterestPostingService { 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); - } - + /** + * The GL account the product maps under one of Fineract's fixed financial account types, as set + * on the product's Accounting tab. + * + *

+ * A missing mapping is reported against the product rather than surfacing as an + * {@code EmptyResultDataAccessException} from inside a batch that has already posted other + * products — the message has to say which product to go and configure. + */ private Long mappedGlAccount(final long productId, final int financialAccountType) { - return jdbcTemplate.queryForObject(""" + final List found = jdbcTemplate.queryForList(""" 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); + if (found.isEmpty()) { + throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type " + + financialAccountType + ". Set the product's Accounting tab before interest can be posted for it."); + } + return found.get(0); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 45c852a78..62bacbc6c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -43,6 +43,8 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -59,6 +61,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi private final ConfigurationDomainService configurationDomainService; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final SavingsDailyInterestService savingsDailyInterestService; + private final VoucherService voucherService; @Autowired public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository, @@ -67,7 +71,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi final JournalEntryWritePlatformService journalEntryWritePlatformService, final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context, final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - final BusinessEventNotifierService businessEventNotifierService) { + final BusinessEventNotifierService businessEventNotifierService, + final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) { this.savingsAccountRepository = savingsAccountRepository; this.savingsAccountTransactionRepository = savingsAccountTransactionRepository; this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper; @@ -76,6 +81,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi this.context = context; this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository; this.businessEventNotifierService = businessEventNotifierService; + this.savingsDailyInterestService = savingsDailyInterestService; + this.voucherService = voucherService; } @Transactional @@ -148,6 +155,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill); } + // As on the deposit path: the ledger's row must follow the balance out, or the account goes on + // earning on money it no longer holds until tonight's run corrects it. + savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today); + + // The debit voucher for the withdrawal, including any withdrawal fee the posting collected — + // one slip, every leg, the way a payment voucher is written at a counter. + if (!transactionBooleanValues.isSkipAccountingPostings()) { + voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_SAVINGS_TRANSACTION, + withdrawal.getId(), context.authenticatedUser(), VoucherService.PREFIX_DEBIT); + } + businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal)); return withdrawal; } @@ -238,6 +256,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi if (!skipAccountingPostings) { postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); } + // The daily-interest ledger states the balance as at its last row, which is yesterday's until + // tonight's run. A closure raised in between reads that row, so money paid in today would earn + // nothing and money paid out would go on earning. Bringing the row up to date here keeps what + // a closure reads in step with what the account holds. + savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today); + + // The credit voucher for the deposit. Raised from the journal entries the posting just wrote, + // so a deposit that also collected a charge carries every leg on ONE slip — which is what the + // member signs and the branch files. This is the path every counter deposit takes (cashier, + // clerk, transfer in), and it raised no voucher at all before. + if (!skipAccountingPostings) { + voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_DEPOSIT, VoucherService.REF_SAVINGS_TRANSACTION, + deposit.getId(), context.authenticatedUser(), VoucherService.PREFIX_CREDIT); + } + businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit)); return deposit; } 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 8afd636c7..7030dd9b0 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 @@ -363,9 +363,10 @@ 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); + // The voucher itself is raised by handleDeposit() above, off the journal entries it posted — + // one slip covering every leg, including any charge collected with the deposit. Read it back + // here only to return it to the caller. + final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId()); if (depositVoucherNo != null) { changes.put("voucherNo", depositVoucherNo); } @@ -446,9 +447,8 @@ 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); + // As on the deposit path: handleWithdrawal() raised the slip; this only reads it back. + final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId()); if (withdrawalVoucherNo != null) { changes.put("voucherNo", withdrawalVoucherNo); } @@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); + // A charge taken from the balance is a journal voucher, not a debit voucher: no cash crosses + // the counter, the member's balance simply moves to an income head. A branch files these in + // the JV book, which is why the series differs from a counter withdrawal that collects a fee. + this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_CHARGE, VoucherService.REF_SAVINGS_TRANSACTION, + chargeTransaction.getId(), this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL); + return chargeTransaction; } @@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi account.escheat(appuserRepository.fetchSystemUser()); this.savingAccountRepositoryWrapper.saveAndFlush(account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); + + // A journal voucher for the balance moved to the escheat liability. Raised under the system + // user, not a branch user: this runs from the dormant-accounts batch with nobody at a + // counter, and the register has to say so. + final AppUser systemUser = appuserRepository.fetchSystemUser(); + for (final SavingsAccountTransaction transaction : account.getTransactions()) { + if (transaction.getId() != null && !existingTransactionIds.contains(transaction.getId()) + && transaction.isAmountOnHoldNotReleased() == false && transaction.isNotReversed()) { + this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_ESCHEAT, REF_SAVINGS_TRANSACTION, + transaction.getId(), systemUser, VoucherService.PREFIX_JOURNAL); + } + } } private AppUser getAppUserIfPresent() { @@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi } this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, - transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT, + transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL, 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"))); + // Without this the voucher exists but nothing ties it to the transaction it paid, so + // the register cannot show the two together and the transaction shows no voucher_no. + this.voucherService.linkSavingsTransaction(REF_SAVINGS_TRANSACTION, transaction.getId(), transaction.getId()); } } 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/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 75c44c2d5..51cb1b193 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 @@ -435,7 +435,14 @@ public class VoucherVerificationApiResource { @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, - @QueryParam("cashOnly") final Boolean cashOnly) { + @QueryParam("cashOnly") final Boolean cashOnly, + @QueryParam("cashiersOnly") final Boolean cashiersOnlyParam, + @QueryParam("systemOnly") final Boolean systemOnlyParam) { + + // Defaults to false so an existing caller that does not send the flag keeps seeing the whole + // register; the two counter screens opt in. + final boolean cashiersOnly = Boolean.TRUE.equals(cashiersOnlyParam); + final boolean systemOnly = Boolean.TRUE.equals(systemOnlyParam); final AppUser currentUser = this.context.authenticatedUser(); final boolean seesEveryone = isCheckerRole(currentUser); @@ -457,6 +464,15 @@ public class VoucherVerificationApiResource { final StringBuilder sql = new StringBuilder(""" SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + -- The date the posting actually belongs to, which is not always the date the + -- voucher was written. A voucher's created_at is a wall-clock instant, while + -- the transaction behind it carries the business date — so a nightly run that + -- finishes after midnight stamps its vouchers with the next day although the + -- ledger sits on the day it worked on. Somebody reading this register today + -- is looking at last night's work, and needs to be told which day it is for. + COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) AS ledger_date, + (COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) + < CAST(v.created_at AS DATE)) AS posted_after_close, 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, @@ -465,6 +481,17 @@ public class VoucherVerificationApiResource { cash.moved AS is_cash FROM m_voucher v LEFT JOIN m_office o ON o.id = v.branch_office_id + -- Carries the business date the posting belongs to, for ledger_date above. + -- + -- Matched on either link. savings_txn_id is stamped by a second step after the + -- voucher is written, and on the interest register it is empty on every row -- + -- reference_id already holds the same transaction id, put there when the voucher + -- was raised. Joining only on savings_txn_id left the ledger date blank on + -- precisely the postings this column exists for. + LEFT JOIN m_savings_account_transaction t + ON t.id = COALESCE(v.savings_txn_id, + CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION' + THEN v.reference_id END) 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 @@ -537,6 +564,38 @@ public class VoucherVerificationApiResource { sql.append(" AND v.voucher_type = ?"); args.add(voucherType.trim()); } + // Narrowed to the people who hold a drawer. + // + // These two screens are the counter's own books: what came in over it and what went out. + // Everyone else's work — a clerk raising a request, a manager approving one, interest a + // nightly run posted — is their own to account for, and each person reads it on My Vouchers. + // Mixed in here it buried the cashier rows these registers exist to reconcile, and left the + // totals disagreeing with any drawer. + // + // Read from m_cashiers rather than from a role name, because holding a drawer is what makes + // somebody a cashier — a role called "Cashier" can be granted to anybody, and somebody + // covering a drawer without that role would otherwise vanish from the register they are + // answerable for. A username is matched through the staff record the drawer belongs to. + if (cashiersOnly) { + sql.append(" AND v.created_by_username IN (SELECT u.username FROM m_cashiers c" + + " JOIN m_appuser u ON u.staff_id = c.staff_id WHERE u.username IS NOT NULL)"); + } + // Postings nobody filed a request for: interest a nightly run credited, a charge collected + // from a balance, an escheat, a journal keyed straight to the ledger. + // + // Identified by what they are not — none of the five registers a person raises through. A + // reference type is stamped at post time and cannot be granted or revoked the way a role + // can, so this holds even when a job runs as somebody's own login rather than as `system`. + // + // These have no home on any other screen: My Vouchers is scoped to the reader's own work + // and a nightly job is nobody's, and the two counter registers deliberately exclude + // everything that did not cross a drawer. Without this filter the only way to see what the + // system posted was to read the whole register and discount the rest by eye. + if (systemOnly) { + sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN (" + + "'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', 'CLERK_CLOSURE_REQUEST'," + + " 'CLERK_DISBURSEMENT_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST'))"); + } // A cashier's register is cash: what came over the counter in notes and coins. // // The test is whether a side of the voucher is the till (the `till` join above). That is @@ -580,6 +639,11 @@ public class VoucherVerificationApiResource { 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()); + // The day the posting belongs to, and whether that differs from the day it was written. + // A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's + // work: the register has to say so, or the reader takes the timestamp for the date. + m.put("ledgerDate", r.get("ledger_date") == null ? null : r.get("ledger_date").toString()); + m.put("postedAfterClose", Boolean.TRUE.equals(r.get("posted_after_close"))); m.put("userId", r.get("created_by")); m.put("username", r.get("created_by_username")); m.put("branchName", r.get("branch_name")); @@ -607,12 +671,212 @@ public class VoucherVerificationApiResource { // from the caller's roles a second time. res.put("canSeeAllUsers", seesEveryone); res.put("cashOnly", restrictToCash); + // So the screen can say whose register it is showing rather than leaving the reader to + // wonder why their own entries are absent from it. + res.put("cashiersOnly", cashiersOnly); res.put("vouchers", out); res.put("count", out.size()); res.put("totalAmount", total); return gson.toJson(res); } + /** + * One user's day: everything they raised and everything they verified, receipts and payments + * together. + * + *

+ * The Received and Payment registers each answer "what came in / went out today", across + * everyone. This answers the other question a branch asks at a close — "what did I do today" — + * and it cannot be assembled from those two, for two reasons. A checker's work leaves no + * voucher of their own: the slip stays in the maker's name, and the only trace of the approval + * is the verification row, which the registers do not read. And a person's day is one list, not + * a receipt list plus a payment list they have to reconcile in their head. + * + *

+ * Each row therefore says which side it is (CR or DR, from the voucher's own series) and what + * the user's part in it was: + *

    + *
  • RAISED — they entered it; + *
  • VERIFIED — somebody else entered it, they checked it; + *
  • RAISED_AND_VERIFIED — both, which a small branch does legitimately (a manager posting and + * verifying their own entry), and which an auditor wants to see marked rather than hidden. + *
+ * + *

+ * Always scoped to the caller, with no username parameter — a checker looking at another + * person's day uses the register with its user filter. This screen is "my work", and a + * parameter that let it be somebody else's would make its own heading a lie. + */ + @GET + @Path("my-work") + public String myWork(@QueryParam("from") final String from, @QueryParam("to") final String to) { + + final AppUser currentUser = this.context.authenticatedUser(); + final String username = currentUser.getUsername(); + + 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 voucher qualifies on any of three counts, so the filter is an OR across them rather + // than three queries stitched together — that way a voucher the user both raised and + // actioned appears once, marked as both, instead of twice. + // + // "Approved" has two distinct meanings in this system and both belong here: + // + // - the maker-checker decision, held as manager_id/checker_id on the register's own row. + // This is the one an approver actually performs and the one they came looking for; it + // raises no voucher of its own, because the slip stays in the maker's name. + // - the physical cash check, held in m_voucher_verification, where somebody counted the + // notes against the slip. + // + // Reading only the second was the bug: a manager who approved a withdrawal all morning saw + // an empty page, because approving is not counting. The registers are unioned into one + // lookup keyed by voucher number, so each register's own approver column is read where it + // actually lives instead of being duplicated onto the voucher. + final List> rows = jdbcTemplate.queryForList(""" + WITH approvals AS ( + SELECT voucher_no, manager_id AS approver_id, manager_name AS approver_name, actioned_at + FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + ), + -- Who submitted the request the voucher belongs to. + -- + -- Needed because m_voucher.created_by does not stay with the maker: when a cashier + -- hands the cash over, updateCashier() reassigns the voucher to them so it reaches + -- the cashier's own register. That reassignment overwrites rather than adds, so the + -- clerk who raised the request disappeared from their own page the moment it was + -- paid out — the register's maker column is the only surviving record of them. + makers AS ( + SELECT voucher_no, clerk_id AS maker_id, status AS request_status + FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + ) + SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + v.created_by_username, o.name AS branch_name, + -- Where the voucher stands. The register's own status is what a person wants: + -- PENDING, APPROVED, WITHDRAWN, REJECTED — the life of the request they + -- raised or passed. The verification status is a different question (has + -- anybody counted the notes against the slip), answered on most days by + -- nobody, so showing only that left the column blank on every row. + COALESCE(mk.request_status, ver.status) AS verification_status, + -- Whoever actioned it, whichever way it was actioned. The maker-checker + -- decision is named first: it is the one a person is answerable for. + COALESCE(ap.approver_name, ver.verified_by_name) AS verified_by_name, + COALESCE(ap.actioned_at, ver.verified_at) AS verified_at, + 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, + (v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me, + (ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me + 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 approvals ap ON ap.voucher_no = v.voucher_no + LEFT JOIN makers mk ON mk.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 < ? + AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?) + ORDER BY v.created_at DESC, v.voucher_no DESC + """, username, currentUser.getId(), currentUser.getId(), currentUser.getId(), + Timestamp.valueOf(fromDate.atStartOfDay()), Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()), + username, currentUser.getId(), currentUser.getId(), currentUser.getId()); + + BigDecimal receivedTotal = BigDecimal.ZERO; + BigDecimal paymentTotal = BigDecimal.ZERO; + BigDecimal journalTotal = BigDecimal.ZERO; + int raisedCount = 0; + int verifiedCount = 0; + final List> out = new java.util.ArrayList<>(); + + for (final Map r : rows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + final String voucherNo = (String) r.get("voucher_no"); + // The series the number was issued in is the side: a register's own prefix is the one + // thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type + // would have to keep a list of types in step with the ones that issue them. + final String side = voucherNo == null ? null + : voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV"; + + if (amount != null) { + if ("CR".equals(side)) { + receivedTotal = receivedTotal.add(amount); + } else if ("DR".equals(side)) { + paymentTotal = paymentTotal.add(amount); + } else { + journalTotal = journalTotal.add(amount); + } + } + + final boolean raisedByMe = Boolean.TRUE.equals(r.get("raised_by_me")); + final boolean verifiedByMe = Boolean.TRUE.equals(r.get("verified_by_me")); + if (raisedByMe) { + raisedCount++; + } + if (verifiedByMe) { + verifiedCount++; + } + + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", voucherNo); + m.put("side", side); + 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("raisedBy", r.get("created_by_username")); + m.put("branchName", r.get("branch_name")); + m.put("verificationStatus", r.get("verification_status")); + m.put("verifiedByName", r.get("verified_by_name")); + m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); + 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")); + final int lineCount = ((Number) r.get("line_count")).intValue(); + m.put("extraLines", Math.max(0, lineCount - 2)); + m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED"); + out.add(m); + } + + final Map res = new LinkedHashMap<>(); + res.put("from", fromDate.toString()); + res.put("to", toDate.toString()); + res.put("username", username); + res.put("displayName", currentUser.getDisplayName()); + res.put("vouchers", out); + res.put("count", out.size()); + res.put("raisedCount", raisedCount); + res.put("verifiedCount", verifiedCount); + res.put("receivedTotal", receivedTotal); + res.put("paymentTotal", paymentTotal); + res.put("journalTotal", journalTotal); + 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()) { 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 b07bb1061..d20398465 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 @@ -24,11 +24,14 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.useradministration.domain.AppUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -57,6 +60,8 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class VoucherService { + private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class); + public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; @@ -66,6 +71,8 @@ public class VoucherService { public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; + /** A share redemption approved through the clerk register, keyed by the request id. */ + public static final String REF_CLERK_SHARE_WITHDRAWAL_REQUEST = "CLERK_SHARE_WITHDRAWAL_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. */ @@ -87,12 +94,20 @@ public class VoucherService { */ public static final String PREFIX_JOURNAL = "JV"; + /** A charge or fee collected from an account. */ + public static final String TYPE_CHARGE = "CHARGE"; + /** An unclaimed balance moved to the escheat liability. */ + public static final String TYPE_ESCHEAT = "ESCHEAT"; + /** 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"; + /** A savings movement, keyed by its m_savings_account_transaction id. */ + public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; @@ -254,6 +269,120 @@ public class VoucherService { return voucherNo; } + /** + * Raises one voucher for one savings transaction, reading its sides from the journal entries the + * posting has already written. + * + *

+ * This is how a branch register actually works: the slip is the transaction, not the leg. A + * deposit that also collects a charge posts three or four GL legs and gets ONE credit voucher + * carrying all of them, because that is what the member signed and what the teller files. Every + * register that builds its lines by hand risks drifting from the ledger it is supposed to + * mirror; this cannot, because it reads the ledger. + * + *

+ * The series follows the transaction's own direction, the way a bank's voucher books are kept: + *

    + *
  • money in over the counter — a deposit — is a credit voucher (CR); + *
  • money out — a withdrawal, a charge collected in cash — is a debit voucher (DR); + *
  • anything that moves between accounts without cash crossing the counter — interest posted, + * a charge taken from the balance, a transfer, an escheat — is a journal voucher (JV). + *
+ * The caller passes the type and prefix because only the caller knows whether cash was involved: + * the same charge is a DR voucher at the counter and a JV when a batch job collects it. + * + *

+ * Returns null, and raises nothing, when the transaction posted no journal entries — an on-hold + * or a release moves no money and has no voucher in a real branch either. A failure is logged + * rather than thrown: the transaction and its GL entries have already committed, and losing the + * slip is worth less than rolling a member's money back. + * + * @param savingsTxnId + * the m_savings_account_transaction id; its journal entries become the voucher lines + * @return the voucher number, or null if none was raised + */ + public String createVoucherFromJournalEntries(final String voucherType, final String referenceType, final Long savingsTxnId, + final AppUser user, final String prefix) { + + if (savingsTxnId == null || !shouldRaiseVoucher()) { + return null; + } + try { + // Reversed legs are excluded: a correction posts its own reversing pair, and including + // both would net the voucher to zero and fail the balance check. + final List> rows = jdbcTemplate.queryForList(""" + SELECT je.type_enum, je.amount, je.account_id, gl.name AS gl_name, gl.gl_code, + je.description + FROM acc_gl_journal_entry je + JOIN acc_gl_account gl ON gl.id = je.account_id + WHERE je.savings_transaction_id = ? + AND (je.reversed IS NULL OR je.reversed = false) + ORDER BY je.type_enum DESC, je.id + """, savingsTxnId); + if (rows.isEmpty()) { + return null; + } + + final Map party = savingsPartyForTransaction(savingsTxnId); + final String partyName = (String) party.get("party_name"); + final String accountNo = (String) party.get("account_no"); + final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue(); + + final List lines = new ArrayList<>(); + for (final Map row : rows) { + final Long glId = row.get("account_id") == null ? null : ((Number) row.get("account_id")).longValue(); + final BigDecimal amount = (BigDecimal) row.get("amount"); + // The member's own side is named after the member; every other leg is named after + // its GL account, which is what a voucher shows for an income or expense head. + final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId); + final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name"); + final String ref = isMemberSide ? accountNo : (String) row.get("gl_code"); + + // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. + final int typeEnum = ((Number) row.get("type_enum")).intValue(); + VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount) + : VoucherLine.creditGl(glId, name, ref, amount); + final String description = (String) row.get("description"); + lines.add(description == null ? line : line.withDescription(description)); + } + + final String voucherNo = createVoucherWithLines(voucherType, referenceType, savingsTxnId, user, prefix, lines); + linkSavingsTransaction(referenceType, savingsTxnId, savingsTxnId); + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for savings transaction {}", savingsTxnId, e); + return null; + } + } + + /** The voucher already raised against a savings transaction, if any. */ + public String findVoucherNoForSavingsTransaction(final Long savingsTxnId) { + if (savingsTxnId == null) { + return null; + } + return jdbcTemplate + .queryForList("SELECT voucher_no FROM m_voucher WHERE savings_txn_id = ? ORDER BY id DESC LIMIT 1", String.class, + savingsTxnId) + .stream().findFirst().orElse(null); + } + + /** The member and account a savings transaction belongs to, plus the product's control GL. */ + private Map savingsPartyForTransaction(final Long savingsTxnId) { + return jdbcTemplate.queryForList(""" + SELECT COALESCE(c.display_name, g.display_name) AS party_name, + sa.account_no, + apm.gl_account_id AS control_gl_id + FROM m_savings_account_transaction t + JOIN m_savings_account sa ON sa.id = t.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 + LEFT JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + AND apm.product_type = 2 AND apm.financial_account_type = 2 + WHERE t.id = ? + LIMIT 1 + """, savingsTxnId).stream().findFirst().orElse(Map.of()); + } + /** * Checks that the lines form a voucher, and returns the value of one side. * 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 09f6075d8..518b7221b 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 @@ -595,4 +595,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml new file mode 100644 index 000000000..b7b61a407 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + 0 + AND COALESCE(sp.interest_calculation_type_enum, 1) <> 2 + ), + replayed AS ( + SELECT t.id, + t.daily_interest, + SUM( + CASE + WHEN bal.balance > 0 + AND (t.min_balance IS NULL OR t.min_balance <= 0 OR bal.balance >= t.min_balance) + THEN bal.balance * t.rate / 100.0 / t.days_in_year + ELSE 0 + END + ) AS backfilled + FROM target t + CROSS JOIN LATERAL generate_series(t.cycle_start_date, t.balance_date - 1, INTERVAL '1 day') AS gs(day) + CROSS JOIN LATERAL ( + SELECT COALESCE(SUM(CASE + WHEN tr.transaction_type_enum IN (1, 3, 8) THEN tr.amount + WHEN tr.transaction_type_enum IN (2, 4, 5, 7) THEN -tr.amount + ELSE 0 END), 0) AS balance + FROM m_savings_account_transaction tr + WHERE tr.savings_account_id = t.savings_account_id + AND tr.transaction_date <= gs.day::date + AND tr.is_reversed = false + ) AS bal + GROUP BY t.id, t.daily_interest + ) + UPDATE m_savings_daily_interest d + SET cycle_cumulative_interest = r.backfilled + r.daily_interest + FROM replayed r + WHERE d.id = r.id + AND r.backfilled > 0; + ]]> + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml new file mode 100644 index 000000000..ffdc8e618 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml @@ -0,0 +1,75 @@ + + + + + + + + + + name = 'Increase Business Date by 1 day' + + + + + + + + name = 'Increase COB Date by 1 day' + + + + + + + UPDATE m_business_date + SET date = CURRENT_DATE + WHERE type IN ('BUSINESS_DATE', 'COB_DATE') + AND date < CURRENT_DATE; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml new file mode 100644 index 000000000..b28b4c8b4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Sync Business Date To System Date' + + + + + + + + + + + + + + + + + + + + + + + name = 'Sync Business Date To System Date' AND node_id IS NULL + + + + + + + + name IN ('Increase Business Date by 1 day', 'Increase COB Date by 1 day') + + + + + + + UPDATE m_business_date + SET date = CURRENT_DATE + WHERE type IN ('BUSINESS_DATE', 'COB_DATE') + AND date < CURRENT_DATE; + + + + diff --git a/set-business-date.ps1 b/set-business-date.ps1 new file mode 100644 index 000000000..45cf1f9ba --- /dev/null +++ b/set-business-date.ps1 @@ -0,0 +1,103 @@ +# Sets the CBS business date - the date Fineract stamps on every transaction. +# +# The business date is NOT the computer clock. It lives in m_business_date and only moves when +# something advances it, so it drifts behind real time and transactions keep landing on the old day. +# That drift is what makes a Demand Draft dated today look like a future date, and what stops the +# day's interest from accruing. +# +# Usage (from the backend folder): +# .\set-business-date.ps1 # today +# .\set-business-date.ps1 2026-09-10 # a specific day, for testing +# .\set-business-date.ps1 +1 # move forward one day +# .\set-business-date.ps1 -1 # move back one day +# .\set-business-date.ps1 -Show # just show what it is now +# +# Restart is NOT needed: the date is read per request. + +param( + [Parameter(Position = 0)] + [string]$Date = '', + + [switch]$Show +) + +$ErrorActionPreference = 'Stop' + +# Same defaults as application.properties; override with env vars if your setup differs. +$PgHost = if ($env:FINERACT_DEFAULT_TENANTDB_HOSTNAME) { $env:FINERACT_DEFAULT_TENANTDB_HOSTNAME } else { 'localhost' } +$PgPort = if ($env:FINERACT_DEFAULT_TENANTDB_PORT) { $env:FINERACT_DEFAULT_TENANTDB_PORT } else { '5432' } +$PgUser = if ($env:FINERACT_DEFAULT_TENANTDB_UID) { $env:FINERACT_DEFAULT_TENANTDB_UID } else { 'postgres' } +$PgDb = if ($env:FINERACT_DEFAULT_TENANTDB_NAME) { $env:FINERACT_DEFAULT_TENANTDB_NAME } else { 'fineract_default' } + +if (-not $env:PGPASSWORD) { + if ($env:FINERACT_DEFAULT_TENANTDB_PWD) { + $env:PGPASSWORD = $env:FINERACT_DEFAULT_TENANTDB_PWD + } else { + Write-Host "Set PGPASSWORD (or FINERACT_DEFAULT_TENANTDB_PWD) first:" -ForegroundColor Yellow + Write-Host ' $env:PGPASSWORD = "your-db-password"' -ForegroundColor Yellow + exit 1 + } +} + +$psql = Get-Command psql -ErrorAction SilentlyContinue +if ($psql) { + $psqlExe = $psql.Source +} else { + $psqlExe = Get-ChildItem 'C:\Program Files\PostgreSQL\*\bin\psql.exe' -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $psqlExe) { + Write-Host "psql.exe not found. Install the PostgreSQL client or add it to PATH." -ForegroundColor Red + exit 1 +} + +function Invoke-Psql([string]$Sql) { + & $psqlExe -h $PgHost -p $PgPort -U $PgUser -d $PgDb -A -F'|' -t -c $Sql + if ($LASTEXITCODE -ne 0) { throw "psql failed (exit $LASTEXITCODE)" } +} + +function Show-Current { + $rows = Invoke-Psql "SELECT type || ' = ' || date FROM m_business_date ORDER BY type;" + $rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object { Write-Host " $($_.Trim())" } + + $enabled = (Invoke-Psql "SELECT enabled FROM c_configuration WHERE name = 'enable-business-date';") -join '' + if ($enabled.Trim() -ne 't') { + Write-Host " (enable-business-date is OFF, so the real clock is used and this date is ignored)" -ForegroundColor DarkGray + } +} + +if ($Show -or $Date -eq '-Show') { + Write-Host "`nCurrent business date:" -ForegroundColor Cyan + Show-Current + Write-Host '' + exit 0 +} + +# Work out the target date. +if ($Date -eq '') { + $targetSql = 'CURRENT_DATE' + $label = "today ($(Get-Date -Format 'yyyy-MM-dd'))" +} elseif ($Date -match '^[+-][0-9]+$') { + $days = [int]$Date + $targetSql = "(SELECT date + $days FROM m_business_date WHERE type = 'BUSINESS_DATE')" + $label = "$(if ($days -ge 0) { "+$days" } else { $days }) day(s) from the current business date" +} elseif ($Date -match '^\d{4}-\d{2}-\d{2}$') { + try { [datetime]::ParseExact($Date, 'yyyy-MM-dd', $null) | Out-Null } + catch { Write-Host "Not a real date: $Date" -ForegroundColor Red; exit 1 } + $targetSql = "DATE '$Date'" + $label = $Date +} else { + Write-Host "Bad argument: '$Date'. Use yyyy-MM-dd, +N, -N, or nothing for today." -ForegroundColor Red + exit 1 +} + +Write-Host "`nBefore:" -ForegroundColor Cyan +Show-Current + +# COB_DATE moves with BUSINESS_DATE: leaving it behind makes close-of-business work on a day that +# has already passed, which reads as a stuck batch rather than a date setting. +Invoke-Psql "UPDATE m_business_date SET date = $targetSql WHERE type IN ('BUSINESS_DATE', 'COB_DATE');" | Out-Null + +Write-Host "`nAfter ($label):" -ForegroundColor Green +Show-Current +Write-Host "`nNo restart needed - the next request picks this up.`n" -ForegroundColor DarkGray