feat: cashier reports, daily interest enhancements and business date sync
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 10:45:04 +05:30
parent 317a308d8c
commit e33e0f23fe
26 changed files with 1951 additions and 157 deletions
@@ -56,6 +56,7 @@ public enum JobName {
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), // EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), // INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
INCREASE_COB_DATE_BY_1_DAY("Increase COB 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_COB("Loan COB"), //
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), // LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), // SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
@@ -256,7 +256,16 @@ public final class PostingPeriod {
this.daysInYear = daysInYear; this.daysInYear = daysInYear;
this.compoundingPeriods = compoundingPeriods; 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(); this.dateOfPostingTransaction = periodInterval.endDate();
} else { } else {
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
@@ -25,6 +25,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure; 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.savings.data.SavingsAccountTransactionEnumData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AccountingProcessorHelper { public class AccountingProcessorHelper {
@@ -89,6 +91,14 @@ public class AccountingProcessorHelper {
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; 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<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
public static void setDepositAccountTypeContext(int depositAccountType) { public static void setDepositAccountTypeContext(int depositAccountType) {
@@ -116,6 +126,10 @@ public class AccountingProcessorHelper {
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
private final ChargeRepositoryWrapper chargeRepositoryWrapper; private final ChargeRepositoryWrapper chargeRepositoryWrapper;
private final BusinessEventNotifierService businessEventNotifierService; 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( public LoanDTO populateLoanDtoFromDTO(
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
@@ -600,6 +614,131 @@ public class AccountingProcessorHelper {
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
} }
/**
* Interest credited to a member, routed through the payable and paid accounts the product names.
*
* <pre>
* 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
* </pre>
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<Long> 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), * 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. * each with their own product GL mapping and entity ID so the correct account number appears per entry.
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// Post journal entry if earned interest amount is greater than // Post journal entry if earned interest amount is greater than
// zero // zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, // Routed through the payable and paid accounts the product names, so the society
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), // can read interest declared but not yet credited as its own balance. Collapses
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); // 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);
} }
} }
@@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository, FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
GLAccountRepository glAccountRepository, OfficeRepository officeRepository, GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper, AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
BusinessEventNotifierService businessEventNotifierService) { BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository, return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper, closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
businessEventNotifierService); businessEventNotifierService, jdbcTemplate);
} }
@Bean @Bean
@@ -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();
}
}
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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;
}
}
@@ -36,6 +36,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -59,6 +60,7 @@ import org.springframework.stereotype.Component;
@Path("/v1/branchday") @Path("/v1/branchday")
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource { public class BranchDayRegisterApiResource {
@@ -543,12 +545,35 @@ public class BranchDayRegisterApiResource {
blockingCashiers.add(name + " (" + String.join(", ", issues) + ")"); 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()) { if (!blockingCashiers.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", if (overrideReason == null || overrideReason.isBlank()) {
"Cannot close the day — the following cashiers have not been reconciled or settled: " throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled",
+ String.join("; ", blockingCashiers) "Cannot close the day — the following cashiers have not been reconciled or settled: "
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed."); + 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 — // 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. // 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 = ? book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
WHERE office_id = ? AND business_date = ? 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, 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)); officeId, Date.valueOf(businessDate));
final Long registerId = ((Number) found.get(0).get("id")).longValue(); final Long registerId = ((Number) found.get(0).get("id")).longValue();
@@ -583,7 +613,7 @@ public class BranchDayRegisterApiResource {
res.put("closingCash", closingCash); res.put("closingCash", closingCash);
res.put("bookCash", bookCash); res.put("bookCash", bookCash);
res.put("cashDifference", difference); res.put("cashDifference", difference);
res.put("forceClosed", isLate); res.put("forceClosed", isLate || forcedOverCashiers);
return gson.toJson(res); return gson.toJson(res);
} }
@@ -37,7 +37,7 @@ public class CashReportApiResource {
private final Gson gson = new Gson(); 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 * 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 * 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 * its own reversal flag; this is the same guard for the counter, read off the transaction the
* breakdown belongs to. * breakdown belongs to.
* *
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD * <p>
* register keys its rows by its own request id, not a savings transaction -- is kept rather than * Two things are excluded, and they are different failures:
* silently dropped. *
* <ul>
* <li>a breakdown whose transaction is reversed -- the money came back, so the notes did too;
* <li>a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier
* code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no
* transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position
* is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit
* Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070.
* </ul>
*
* <p>
* 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 (" private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt " + " 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. * 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 // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
// step to reach. // step to reach.
rows.addAll(jdbcTemplate.query(""" 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, 'SAVINGS' AS account_type, r.client_name AS party,
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
r.payment_type_name, r.description, r.status, 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) AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate)); """, 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 // 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. // ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
@@ -218,6 +342,10 @@ public class CashReportApiResource {
final List<Map<String, Object>> filtered = new ArrayList<>(); final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO; BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = 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<String, Object> row : rows) { for (final Map<String, Object> row : rows) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) { 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 // 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. // both states cannot double-count it; only 'CLOSED' is unique to that register.
final String rowStatus = (String) row.get("status"); final String rowStatus = (String) row.get("status");
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) // A batch that posted against yesterday is listed today but is not today's money: it
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { // 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"); final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) { if (amount != null) {
if ("CR".equals(row.get("side"))) { 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) -> { filtered.sort((a, b) -> {
@@ -272,9 +414,28 @@ public class CashReportApiResource {
result.put("creditTotal", creditTotal); result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal); result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(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); 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<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>(); final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no")); 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 // 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. // where the entry came from, not who typed it.
row.put("section", rs.getString("section")); 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("side", rs.getString("side"));
row.put("kind", rs.getString("kind")); row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no")); row.put("accountNo", rs.getString("account_no"));
@@ -334,39 +501,93 @@ public class CashReportApiResource {
+ " SELECT au.id FROM m_appuser au " + " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Single query — conditional aggregation for deposit, withdrawal, net per denomination // Every note and coin that reached the drawer or left it, 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, " // Four legs, not one. The counter breakdown alone answered "what did customers hand over",
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, " // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, " // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
+ "FROM m_savings_txn_denomination std " // counted against. So the vault allocation and the settlement back are included on the same
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " // footing as the counter work, and each row says where its notes came from.
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED //
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind " // Cashier scoping differs per leg because each table records the party differently: the
+ "ORDER BY std.denomination_value DESC, dm.kind"; // 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<Object> args = new ArrayList<>(); final List<Object> args = new ArrayList<>();
args.add(txnDate); args.add(txnDate);
if (scopedCashierId != null) { if (scopedCashierId != null) {
args.add(scopedCashierId); args.add(scopedCashierId);
} }
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count"); final long vaultInCount = rs.getLong("vault_in_count");
long withdrawalCount = rs.getLong("withdrawal_count"); final long depositCount = rs.getLong("deposit_count");
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); final long withdrawalCount = rs.getLong("withdrawal_count");
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); 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<String, Object> row = new LinkedHashMap<>(); Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id")); row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value")); row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind")); row.put("kind", rs.getString("kind"));
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount); row.put("depositCount", depositCount);
row.put("depositAmount", depositAmount); row.put("depositAmount", depositAmount);
row.put("withdrawalCount", withdrawalCount); row.put("withdrawalCount", withdrawalCount);
row.put("withdrawalAmount", withdrawalAmount); row.put("withdrawalAmount", withdrawalAmount);
row.put("netCount", depositCount - withdrawalCount); row.put("settledOutCount", settledOutCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount)); 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; return row;
}, args.toArray()); }, args.toArray());
@@ -488,10 +709,49 @@ public class CashReportApiResource {
result.put("openingBalance", openingBalance); result.put("openingBalance", openingBalance);
result.put("closingBalance", closingBalance); result.put("closingBalance", closingBalance);
result.put("cashMovement", closingBalance.subtract(openingBalance)); 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); 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. * 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); return gson.toJson(result);
} }
private Map<String, Object> 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.
*
* <p>
* 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<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate // 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 " 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 " + "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; 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.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -57,6 +58,9 @@ public class CashSettlementApiResource {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
private final VoucherService voucherService; 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(); private final Gson gson = new Gson();
@POST @POST
@@ -112,6 +116,8 @@ public class CashSettlementApiResource {
"At least one denomination with a positive count is required."); "At least one denomination with a positive count is required.");
} }
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
final BigDecimal total = sumTotal(denominations); final BigDecimal total = sumTotal(denominations);
CashSettlement header = new CashSettlement(); CashSettlement header = new CashSettlement();
@@ -290,6 +296,67 @@ public class CashSettlementApiResource {
return total; return total;
} }
/**
* Refuses a hand-over of notes the cashier is not holding.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
settlementDate.toString());
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> 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) { private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) { for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true; if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; 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.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer; import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
@@ -52,6 +53,9 @@ public class CashTransferApiResource {
private final PlatformTransactionManager transactionManager; private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper; 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(); private final Gson gson = new Gson();
@POST @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 * 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 * 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 * 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 * vault actually holds of it.
* settlements-in minus today's transfers-out actually holds of that specific denomination.
* *
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a * <p>
* denomination against, so this is skipped rather than blocking every transfer on a day that * The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
* simply never captured one. * 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.
*
* <p>
* 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, private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
final JsonArray denominations, final java.sql.Date businessDate) { final JsonArray denominations, final java.sql.Date businessDate) {
final List<Object[]> openingRows = jdbcTemplate.query(""" final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
WHERE register_id = ? AND leg = 'OPENING'
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
if (openingRows.isEmpty()) {
return; return;
} }
final Map<Long, Integer> available = new java.util.HashMap<>(); final Map<Long, Integer> available = new java.util.HashMap<>();
for (final Object[] row : openingRows) { @SuppressWarnings("unchecked")
available.merge((Long) row[0], (Integer) row[2], Integer::sum); final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
} if (heldRows != null) {
for (final Map<String, Object> row : heldRows) {
final List<Object[]> settledRows = jdbcTemplate.query(""" final Object id = row.get("denominationId");
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd final Object count = row.get("count");
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id if (id instanceof Number && count instanceof Number) {
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
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<Object[]> 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);
} }
for (final JsonElement el : denominations) { for (final JsonElement el : denominations) {
@@ -215,6 +215,31 @@ public class ClerkClosureRequestApiResource {
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) { private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false); final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
savingsAccountWritePlatformService.postInterest(account, true, closureDate, 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()); return zeroIfNull(account.getSummary().getAccountBalance());
} }
@@ -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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; 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.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -77,10 +81,13 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ClerkShareWithdrawalRequestApiResource { public class ClerkShareWithdrawalRequestApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper; private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date, // 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 // 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 // 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. // shares left for the native close command to force-redeem without oversight.
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString()); 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(); final JsonObject res = new JsonObject();
res.addProperty("resourceId", id); res.addProperty("resourceId", id);
res.addProperty("status", "APPROVED"); res.addProperty("status", "APPROVED");
if (voucherNo != null) {
res.addProperty("voucherNo", voucherNo);
}
if (shareTxnId != null) { if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId); res.addProperty("shareTransactionId", shareTxnId);
} }
@@ -742,6 +757,62 @@ public class ClerkShareWithdrawalRequestApiResource {
return true; return true;
} }
/**
* The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings.
*
* <p>
* 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.
*
* <p>
* 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<VoucherLine> 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) {} private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
/** /**
@@ -61,6 +61,16 @@ public class SavingsDailyInterestOrchestrator {
ORDER BY s.savings_product_id 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<String> failures = new java.util.ArrayList<>();
for (final Map<String, Object> schedule : schedules) { for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue(); final long productId = ((Number) schedule.get("savings_product_id")).longValue();
final Long dueCount = jdbcTemplate.queryForObject(""" final Long dueCount = jdbcTemplate.queryForObject("""
@@ -80,14 +90,26 @@ public class SavingsDailyInterestOrchestrator {
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
// The product rolled back on its own; record why and let the others run. // 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); 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(""" jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ? SET status = 'FAILED', failure_reason = ?
WHERE savings_product_id = ? AND posting_date = ? WHERE savings_product_id = ? AND posting_date = ?
""", truncate(e.getMessage()), productId, businessDate); """, truncate(e.getMessage()), productId, businessDate);
updateSchedule(productId, businessDate, "FAILED"); 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));
}
} }
/** /**
@@ -23,6 +23,7 @@ import java.math.MathContext;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; 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 // 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" // 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. // 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<Map<String, Object>> previous = jdbcTemplate.queryForList(""" final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
cycle_start_date, cycle_end_date, is_posting_day cycle_start_date, cycle_end_date, is_posting_day
FROM m_savings_daily_interest FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date < ? WHERE savings_account_id = ? AND balance_date < ?
ORDER BY balance_date DESC
LIMIT 1
""", accountId, businessDate); """, accountId, businessDate);
final BigDecimal openingBalance; final BigDecimal openingBalance;
@@ -208,8 +217,6 @@ public class SavingsDailyInterestService {
final LocalDate cycleEnd; final LocalDate cycleEnd;
if (previous.isEmpty()) { if (previous.isEmpty()) {
openingBalance = BigDecimal.ZERO;
carriedInterest = BigDecimal.ZERO;
cycleNumber = 1; cycleNumber = 1;
// The first row belongs to the cycle the day it records falls in, and starts at // 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 // 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, final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
financialYearBeginningMonth); financialYearBeginningMonth);
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart; 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 { } else {
final Map<String, Object> prev = previous.get(0); final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance"); 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 // 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. // whichever balance the account actually earns on, which is not always today's closing.
final boolean averageDailyBalance = calcType != null final boolean averageDailyBalance = averageDailyBalanceOf(calcType);
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
final BigDecimal dailyInterest; final BigDecimal dailyInterest;
final BigDecimal cumulative; 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 * 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). * 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<Map<String, Object>> 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.
*
* <p>
* 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.
*
* <p>
* 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<LocalDate, BigDecimal> 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<LocalDate, BigDecimal> movementsBetween(final Long accountId, final LocalDate from, final LocalDate to) {
final Map<LocalDate, BigDecimal> 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) { private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
final BigDecimal movement = jdbcTemplate.queryForObject(""" final BigDecimal movement = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE SELECT COALESCE(SUM(CASE
@@ -41,19 +41,23 @@ import org.springframework.transaction.annotation.Transactional;
* holds the figure that would say so. * holds the figure that would say so.
* *
* This posts the product's total first, into the liability the product names as its Savings Interest * 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:
* *
* <pre> * <pre>
* Dr Savings Interest Paid / Interest On Savings (expense, from the product) * Dr Interest On Savings (expense, from the product)
* Cr Savings Interest Payable (liability, from the product) * Cr Savings Interest Payable (liability, from the product) what the bank now owes
* *
* Dr Savings Interest Payable (per member) * Dr Savings Interest Payable
* Cr Savings Control (per member, from the product) * 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
* </pre> * </pre>
* *
* Every account named above is read from the product's own GL configuration; none is written into * 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, * this class. A product that names no paid account collapses the middle step - both legs resolve to
* which keeps products predating that mapping posting exactly as before. * 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 * 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 * 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; 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 * Reserved names in the product's own GL mappings, which is where the middle two legs of the
* bill are configured. The names are the contract between this service and the product screen — * interest bill are configured. The names are the contract between this service and the product
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account. * 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_PAYABLE = "Savings Interest Payable";
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid"; 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 */ /** m_savings_account_transaction.transaction_type_enum */
private static final int INTEREST_POSTING = 3; private static final int INTEREST_POSTING = 3;
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */ /** acc_gl_journal_entry.entity_type_enum for a savings entry. */
private static final int ENTITY_TYPE_SAVINGS = 2; 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_INTEREST_ON_SAVINGS = 3;
private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -164,33 +167,39 @@ public class SavingsPooledInterestPostingService {
largest.get("savings_account_id")); largest.get("savings_account_id"));
} }
// The expense the bill is recognised against, and the liability it sits in until it reaches // The three accounts the bill passes through on its way to the members. Every one is read
// the members. Both come from the product: "Savings Interest Paid" if the product names one, // from the product's own configuration — the standard Interest On Savings mapping, and the
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the // two reserved names on the product's Accounting tab — so a bank on its own chart of
// liability, falling back to the control accounts only where a product configures neither. // accounts is posted to the accounts it chose. No GL code is named in this class.
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), //
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS)); // Where a product leaves a reserved name unset the bill still has somewhere to go: the
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE); // payable falls back to the product's Savings Control (interest owed is a liability either
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL)); // way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL); // 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 Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code"); final String currency = (String) due.get(0).get("currency_code");
final String poolReference = "SIP-" + productId + "-" + postingDate; 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, 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 // Skipped where the product names no separate paid account: both legs then resolve to the
// would debit and credit the same account for the same amount — a pair of entries that say // payable and this would debit and credit the same account for the same amount — a pair of
// nothing and clutter the ledger. Only a product still on the two distinct control accounts // entries that say nothing and clutter the ledger.
// needs the transfer written. if (!payableGl.equals(paidGl)) {
if (!postingControlGl.equals(postedControlGl)) { journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
} }
// Stage 3: out to the members, one at a time. // 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, final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance); newBalance);
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference,
accountId); accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId); accountId, transactionId);
@@ -309,7 +318,14 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date) created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) 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", debit ? "Savings interest posting" : "Savings interest posted",
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date); systemUserId(), systemUserId(), date);
@@ -325,16 +341,26 @@ public class SavingsPooledInterestPostingService {
return ids.isEmpty() ? 1L : ids.get(0); 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.
*
* <p>
* 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) { private Long mappedGlAccount(final long productId, final int financialAccountType) {
return jdbcTemplate.queryForObject(""" final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl_account_id FROM acc_product_mapping SELECT gl_account_id FROM acc_product_mapping
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
LIMIT 1 LIMIT 1
""", Long.class, productId, financialAccountType); """, 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);
} }
/** /**
@@ -43,6 +43,8 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; 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.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -59,6 +61,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final BusinessEventNotifierService businessEventNotifierService; private final BusinessEventNotifierService businessEventNotifierService;
private final SavingsDailyInterestService savingsDailyInterestService;
private final VoucherService voucherService;
@Autowired @Autowired
public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository, public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository,
@@ -67,7 +71,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final JournalEntryWritePlatformService journalEntryWritePlatformService, final JournalEntryWritePlatformService journalEntryWritePlatformService,
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context, final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
final BusinessEventNotifierService businessEventNotifierService) { final BusinessEventNotifierService businessEventNotifierService,
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) {
this.savingsAccountRepository = savingsAccountRepository; this.savingsAccountRepository = savingsAccountRepository;
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository; this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper; this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
@@ -76,6 +81,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
this.context = context; this.context = context;
this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository; this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository;
this.businessEventNotifierService = businessEventNotifierService; this.businessEventNotifierService = businessEventNotifierService;
this.savingsDailyInterestService = savingsDailyInterestService;
this.voucherService = voucherService;
} }
@Transactional @Transactional
@@ -148,6 +155,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill); 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)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
return withdrawal; return withdrawal;
} }
@@ -238,6 +256,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
if (!skipAccountingPostings) { if (!skipAccountingPostings) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); 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)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
return deposit; return deposit;
} }
@@ -363,9 +363,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note); this.noteRepository.save(note);
} }
// Money arriving at the counter: cash is debited and the member's savings control account // The voucher itself is raised by handleDeposit() above, off the journal entries it posted —
// is credited. // one slip covering every leg, including any charge collected with the deposit. Read it back
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true); // here only to return it to the caller.
final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId());
if (depositVoucherNo != null) { if (depositVoucherNo != null) {
changes.put("voucherNo", depositVoucherNo); changes.put("voucherNo", depositVoucherNo);
} }
@@ -446,9 +447,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.noteRepository.save(note); this.noteRepository.save(note);
} }
// Money leaving the member over the counter: their savings control account is debited and // As on the deposit path: handleWithdrawal() raised the slip; this only reads it back.
// the till that pays it out is credited. final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId());
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
if (withdrawalVoucherNo != null) { if (withdrawalVoucherNo != null) {
changes.put("voucherNo", withdrawalVoucherNo); changes.put("voucherNo", withdrawalVoucherNo);
} }
@@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); 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; return chargeTransaction;
} }
@@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
account.escheat(appuserRepository.fetchSystemUser()); account.escheat(appuserRepository.fetchSystemUser());
this.savingAccountRepositoryWrapper.saveAndFlush(account); this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); 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() { private AppUser getAppUserIfPresent() {
@@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
} }
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, 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) List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
.withDescription("Interest posted to " + accountNo), .withDescription("Interest posted to " + accountNo),
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
.withDescription("Interest credited"))); .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) { } catch (final RuntimeException e) {
log.error("Could not raise interest vouchers for savings account {}", account.getId(), 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<String, Object> 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<VoucherLine> 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;
}
}
} }
@@ -435,7 +435,14 @@ public class VoucherVerificationApiResource {
@Path("report") @Path("report")
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType, @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 AppUser currentUser = this.context.authenticatedUser();
final boolean seesEveryone = isCheckerRole(currentUser); final boolean seesEveryone = isCheckerRole(currentUser);
@@ -457,6 +464,15 @@ public class VoucherVerificationApiResource {
final StringBuilder sql = new StringBuilder(""" final StringBuilder sql = new StringBuilder("""
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, 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, v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
ver.status AS verification_status, ver.status AS verification_status,
dr.party_name AS debit_party, dr.account_no AS debit_account, dr.party_name AS debit_party, dr.account_no AS debit_account,
@@ -465,6 +481,17 @@ public class VoucherVerificationApiResource {
cash.moved AS is_cash cash.moved AS is_cash
FROM m_voucher v FROM m_voucher v
LEFT JOIN m_office o ON o.id = v.branch_office_id 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 m_voucher_verification ver ON ver.voucher_no = v.voucher_no
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT party_name, account_no FROM m_voucher_line SELECT party_name, account_no FROM m_voucher_line
@@ -537,6 +564,38 @@ public class VoucherVerificationApiResource {
sql.append(" AND v.voucher_type = ?"); sql.append(" AND v.voucher_type = ?");
args.add(voucherType.trim()); 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. // 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 // 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("voucherType", r.get("voucher_type"));
m.put("amount", amount); m.put("amount", amount);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); 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("userId", r.get("created_by"));
m.put("username", r.get("created_by_username")); m.put("username", r.get("created_by_username"));
m.put("branchName", r.get("branch_name")); m.put("branchName", r.get("branch_name"));
@@ -607,12 +671,212 @@ public class VoucherVerificationApiResource {
// from the caller's roles a second time. // from the caller's roles a second time.
res.put("canSeeAllUsers", seesEveryone); res.put("canSeeAllUsers", seesEveryone);
res.put("cashOnly", restrictToCash); 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("vouchers", out);
res.put("count", out.size()); res.put("count", out.size());
res.put("totalAmount", total); res.put("totalAmount", total);
return gson.toJson(res); return gson.toJson(res);
} }
/**
* One user's day: everything they raised and everything they verified, receipts and payments
* together.
*
* <p>
* 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.
*
* <p>
* 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:
* <ul>
* <li>RAISED — they entered it;
* <li>VERIFIED — somebody else entered it, they checked it;
* <li>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.
* </ul>
*
* <p>
* 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<Map<String, Object>> 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<Map<String, Object>> out = new java.util.ArrayList<>();
for (final Map<String, Object> 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<String, Object> 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<String, Object> 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. */ /** 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) { private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
@@ -24,11 +24,14 @@ import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -57,6 +60,8 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class VoucherService { public class VoucherService {
private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class);
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DEPOSIT = "DEPOSIT";
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; 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_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; public static final String REF_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"; 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. */ /** 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"; 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. */ /** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION"; public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */ /** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY"; 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 static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -254,6 +269,120 @@ public class VoucherService {
return voucherNo; return voucherNo;
} }
/**
* Raises one voucher for one savings transaction, reading its sides from the journal entries the
* posting has already written.
*
* <p>
* 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.
*
* <p>
* The series follows the transaction's own direction, the way a bank's voucher books are kept:
* <ul>
* <li>money in over the counter — a deposit — is a <b>credit voucher</b> (CR);
* <li>money out — a withdrawal, a charge collected in cash — is a <b>debit voucher</b> (DR);
* <li>anything that moves between accounts without cash crossing the counter — interest posted,
* a charge taken from the balance, a transfer, an escheat — is a <b>journal voucher</b> (JV).
* </ul>
* 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.
*
* <p>
* 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<Map<String, Object>> 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<String, Object> 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<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> 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<String, Object> 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. * Checks that the lines form a voucher, and returns the value of one side.
* *
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} 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.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} 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.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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} 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.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -595,4 +595,7 @@
<include file="parts/0530_drop_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/> <include file="parts/0530_drop_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
<include file="parts/0531_disable_stock_savings_interest_job.xml" relativeToChangelogFile="true"/> <include file="parts/0531_disable_stock_savings_interest_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0532_sponsor_dd_amendment_request.xml" relativeToChangelogFile="true"/> <include file="parts/0532_sponsor_dd_amendment_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0533_backfill_savings_daily_interest_from_cycle_start.xml" relativeToChangelogFile="true"/>
<include file="parts/0534_enable_business_date_advance_jobs.xml" relativeToChangelogFile="true"/>
<include file="parts/0535_sync_business_date_to_system_date_job.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Restores the interest that accounts already in the ledger earned before their first row.
The ledger writes one row per account, carrying the cycle's running total forward. Its first
row for an account used to start that total at zero, so an account whose ledger opened partway
through a cycle lost every day between the cycle's start and that first row. An account opened
on 24 August whose ledger opened on 3 September carried one day of interest instead of eleven,
and a closure paid out exactly that.
SavingsDailyInterestService now replays those days when it writes an account's first row, so
this is only needed for accounts whose first row was already written. It recomputes the running
total the same way: day by day from the account's own transactions, at the account's rate, with
the minimum-balance cliff applied per day.
Deliberately not touching:
- rows already posted (savings_transaction_id IS NOT NULL) — the money has moved and the
journal entries are written; restating the ledger behind them would put the two out of step.
The shortfall on those is a correction to make deliberately, not silently in a migration.
- average-daily-balance products, which restate the whole cycle rather than accumulating per
day. None are configured here; one would need the average recomputed, not a per-day sum.
-->
<changeSet id="0533-backfill-savings-daily-interest-from-cycle-start" author="Fineract" context="tenant">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
</preConditions>
<sql splitStatements="false"><![CDATA[
WITH target AS (
SELECT d.id,
d.savings_account_id,
d.balance_date,
d.cycle_start_date,
d.daily_interest,
sa.nominal_annual_interest_rate AS rate,
sa.min_balance_for_interest_calculation AS min_balance,
CASE
WHEN sp.interest_calculation_days_in_year_type_enum = 360 THEN 360
WHEN sp.interest_calculation_days_in_year_type_enum = 365 THEN 365
ELSE 365
END AS days_in_year
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
JOIN m_savings_product sp ON sp.id = d.savings_product_id
WHERE d.savings_transaction_id IS NULL
AND d.cycle_start_date < d.balance_date
AND sa.nominal_annual_interest_rate > 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;
]]></sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Starts the two jobs that roll the business date over at midnight.
With enable-business-date on, every transaction is stamped with m_business_date, not the
clock. Nothing here advanced it — the branch day-close keeps its own register and does not
touch it — so it sat on whatever day it was last set to while real time moved on, and each
day of drift did visible damage:
- a Demand Draft raised today was rejected as "the journal entry cannot be made for a
future date", because today really was in the future relative to the stamped date;
- the day's interest never accrued, so a closure paid out a day or more short;
- transactions landed on the stale date, and staff read that as the screen being wrong.
Both jobs ship with Fineract and were simply inactive. They run at 00:05, ahead of the
savings daily interest run at 00:30, so the accrual sees the new day rather than repeating
the old one. COB_DATE moves with BUSINESS_DATE: left behind, close-of-business would keep
working a day that has already passed.
Testing against a chosen date is unaffected — set-business-date.ps1 in the backend root still
sets any date on demand. This only decides what happens when nobody sets one.
-->
<changeSet author="fineract" id="1">
<update tableName="job">
<column name="is_active" valueBoolean="true"/>
<column name="cron_expression" value="0 5 0 1/1 * ? *"/>
<where>name = 'Increase Business Date by 1 day'</where>
</update>
</changeSet>
<changeSet author="fineract" id="2">
<update tableName="job">
<column name="is_active" valueBoolean="true"/>
<column name="cron_expression" value="0 5 0 1/1 * ? *"/>
<where>name = 'Increase COB Date by 1 day'</where>
</update>
</changeSet>
<!--
Brings the date to today on the way in. The jobs only ever add a day at a time, so a tenant
restored from a backup taken weeks ago would otherwise crawl forward one night per day and
keep failing Demand Drafts the whole time.
-->
<changeSet author="fineract" id="3">
<sql>
UPDATE m_business_date
SET date = CURRENT_DATE
WHERE type IN ('BUSINESS_DATE', 'COB_DATE')
AND date &lt; CURRENT_DATE;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Keeps the business date on the calendar date, checked at midnight.
Replaces the two stock jobs enabled in 0534. Those add a day to whatever the date already
holds, which only equals "today" while the scheduler has never missed a night. A server
stopped over a weekend, or a tenant restored from an older dump, leaves the date behind by
the runs that did not happen — and it stays behind, because adding a day to a stale date
gives another stale date. This one reads the clock, so however many days were missed, the
next run lands on today.
It runs at 00:00, ahead of the savings daily interest run at 00:30, so the accrual sees the
new day rather than repeating the old one.
COB_DATE is not scheduled separately: enable-automatic-cob-date-adjustment moves it whenever
BUSINESS_DATE is written, so a second job would only race with the first.
-->
<changeSet author="fineract" id="1">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM job WHERE name = 'Sync Business Date To System Date'
</sqlCheck>
</not>
</preConditions>
<insert tableName="job">
<column name="name" value="Sync Business Date To System Date"/>
<column name="display_name" value="Sync Business Date To System Date"/>
<column name="short_name" value="BDT_SYNC"/>
<column name="cron_expression" value="0 0 0 1/1 * ? *"/>
<column name="create_time" valueComputed="CURRENT_TIMESTAMP"/>
<column name="task_priority" valueNumeric="1"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="scheduler_group" valueNumeric="0"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="is_mismatched_job" valueBoolean="false"/>
</insert>
</changeSet>
<!--
node_id, which the insert above leaves NULL.
Set in its own changeSet rather than added to that insert: changeSet 1 has already run on
existing installs, and editing a changeSet that has run changes its checksum, which
Liquibase refuses at startup. So this repairs those installs and covers a fresh one in the
same statement - on a fresh database it runs immediately after the insert and fills the
column before anything reads it.
Why it matters: retrieveAllJobs() selects a node's jobs with `where node_id = :nodeId` and
the platform's own node id is 1, which a NULL never matches. Without this the row sits in
the job table looking active, with an empty job_key and no next_run_time, and nothing ever
runs it - so the business date silently stops advancing, with 0534 having already switched
the two increment jobs off. Every job Fineract seeds in 0002_initial_data.xml sets
node_id = 1; this one simply did not, and the symptom is silence rather than an error.
-->
<changeSet author="fineract" id="4">
<update tableName="job">
<column name="node_id" valueNumeric="1"/>
<where>name = 'Sync Business Date To System Date' AND node_id IS NULL</where>
</update>
</changeSet>
<!--
The two increment-by-one jobs 0534 turned on. Left running alongside this they would push the
date a day past the clock every night, which is worse than lagging: transactions would be
stamped into the future and rejected as such.
-->
<changeSet author="fineract" id="2">
<update tableName="job">
<column name="is_active" valueBoolean="false"/>
<where>name IN ('Increase Business Date by 1 day', 'Increase COB Date by 1 day')</where>
</update>
</changeSet>
<!--
Start from today rather than waiting for the first midnight, so an install that comes up on a
stale date is correct immediately. Only ever brought forward: a date deliberately set ahead
for testing is left where it is.
-->
<changeSet author="fineract" id="3">
<sql>
UPDATE m_business_date
SET date = CURRENT_DATE
WHERE type IN ('BUSINESS_DATE', 'COB_DATE')
AND date &lt; CURRENT_DATE;
</sql>
</changeSet>
</databaseChangeLog>
+103
View File
@@ -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