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
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:
+1
@@ -56,6 +56,7 @@ public enum JobName {
|
||||
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
||||
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
||||
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
||||
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
|
||||
LOAN_COB("Loan COB"), //
|
||||
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
||||
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
||||
|
||||
+10
-1
@@ -256,7 +256,16 @@ public final class PostingPeriod {
|
||||
this.daysInYear = daysInYear;
|
||||
this.compoundingPeriods = compoundingPeriods;
|
||||
|
||||
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||
if (isUserPosting) {
|
||||
// "Post interest as on D" — a closure, or the same action from the account screen. The
|
||||
// period it covers ends at D-1, because interest is earned up to but not including the
|
||||
// day it is paid, and SavingsHelper closes the period there. The transaction itself
|
||||
// still belongs on D: that is the day the member was credited, the day the branch did
|
||||
// the work, and the day the cash is handed over. Dating it to the period end instead
|
||||
// stamps it a day before the account was even closed, and the ledger then disagrees with
|
||||
// the closure request, the voucher and the passbook — all of which carry D.
|
||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||
this.dateOfPostingTransaction = periodInterval.endDate();
|
||||
} else {
|
||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||
|
||||
+139
@@ -25,6 +25,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||
@@ -77,6 +78,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class AccountingProcessorHelper {
|
||||
@@ -89,6 +91,14 @@ public class AccountingProcessorHelper {
|
||||
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
||||
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
||||
|
||||
/**
|
||||
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
|
||||
* posting is routed through. The names are the contract with the product screen; the GL codes
|
||||
* behind them are the bank's choice, so no code is named here.
|
||||
*/
|
||||
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
|
||||
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
|
||||
|
||||
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
||||
|
||||
public static void setDepositAccountTypeContext(int depositAccountType) {
|
||||
@@ -116,6 +126,10 @@ public class AccountingProcessorHelper {
|
||||
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
||||
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
|
||||
// JSON document rather than in acc_product_mapping, so they are read directly. See
|
||||
// namedSavingsGlAccount.
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public LoanDTO populateLoanDtoFromDTO(
|
||||
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
||||
@@ -600,6 +614,131 @@ public class AccountingProcessorHelper {
|
||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest credited to a member, routed through the payable and paid accounts the product names.
|
||||
*
|
||||
* <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),
|
||||
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
||||
|
||||
+6
-3
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
// Routed through the payable and paid accounts the product names, so the society
|
||||
// can read interest declared but not yet credited as its own balance. Collapses
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
|
||||
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
||||
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
||||
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
||||
BusinessEventNotifierService businessEventNotifierService) {
|
||||
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
|
||||
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
||||
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
||||
businessEventNotifierService);
|
||||
businessEventNotifierService, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
-3
@@ -36,6 +36,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
@@ -59,6 +60,7 @@ import org.springframework.stereotype.Component;
|
||||
@Path("/v1/branchday")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class BranchDayRegisterApiResource {
|
||||
|
||||
@@ -543,12 +545,35 @@ public class BranchDayRegisterApiResource {
|
||||
blockingCashiers.add(name + " (" + String.join(", ", issues) + ")");
|
||||
}
|
||||
}
|
||||
// Overridden only by an explicit reason, and never silently.
|
||||
//
|
||||
// The block is the right default: a day closed over a drawer nobody counted is a day whose
|
||||
// cash figure means nothing. But it cannot be absolute either. A cashier who reconciled on
|
||||
// the wrong date, went on leave, or left the branch mid-shift leaves a day that can never be
|
||||
// closed at all, and every day after it inherits the same block — while the screen offers a
|
||||
// Force Close Day button that could not actually get past this throw, so pressing it
|
||||
// returned the same refusal and read as a broken button.
|
||||
//
|
||||
// So a reason gets you through, and the names of the cashiers who were not reconciled are
|
||||
// appended to it on the record. Somebody reading that day later sees both that it was forced
|
||||
// and exactly whose cash was unaccounted for — which is more use than a day that cannot be
|
||||
// closed and a button that does nothing.
|
||||
String overrideReason = forceReason;
|
||||
if (!blockingCashiers.isEmpty()) {
|
||||
if (overrideReason == null || overrideReason.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled",
|
||||
"Cannot close the day — the following cashiers have not been reconciled or settled: "
|
||||
+ String.join("; ", blockingCashiers)
|
||||
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed.");
|
||||
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day"
|
||||
+ " can be closed, or record a reason to force the close.");
|
||||
}
|
||||
overrideReason = overrideReason + " [Forced over unreconciled cashiers: " + String.join("; ", blockingCashiers)
|
||||
+ "]";
|
||||
log.warn("Branch day {} for office {} force-closed over unreconciled cashiers: {}", businessDate, officeId,
|
||||
String.join("; ", blockingCashiers));
|
||||
}
|
||||
// A forced close is a forced close whether or not the date is also in the past.
|
||||
final boolean forcedOverCashiers = !blockingCashiers.isEmpty();
|
||||
|
||||
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
||||
// but it must say why it was left open, and it is stamped as a force close.
|
||||
@@ -568,8 +593,13 @@ public class BranchDayRegisterApiResource {
|
||||
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
||||
WHERE office_id = ? AND business_date = ?
|
||||
""",
|
||||
// Forced either way: a late close, or a close over a drawer nobody counted. The reason is
|
||||
// stored whenever it was needed, not only when the date was in the past — a same-day
|
||||
// close forced over an unreconciled cashier used to drop its reason on the floor, which
|
||||
// left the one fact a reader would want to know about that day unrecorded.
|
||||
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
|
||||
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
|
||||
bookCash, difference, isLate || forcedOverCashiers,
|
||||
(isLate || forcedOverCashiers) ? overrideReason : null, remarks,
|
||||
officeId, Date.valueOf(businessDate));
|
||||
|
||||
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
||||
@@ -583,7 +613,7 @@ public class BranchDayRegisterApiResource {
|
||||
res.put("closingCash", closingCash);
|
||||
res.put("bookCash", bookCash);
|
||||
res.put("cashDifference", difference);
|
||||
res.put("forceClosed", isLate);
|
||||
res.put("forceClosed", isLate || forcedOverCashiers);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
|
||||
+294
-24
@@ -37,7 +37,7 @@ public class CashReportApiResource {
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Excludes reversed counter transactions from a cash figure.
|
||||
* Excludes counter breakdowns that must not count towards a drawer.
|
||||
*
|
||||
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
|
||||
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
|
||||
@@ -46,13 +46,44 @@ public class CashReportApiResource {
|
||||
* its own reversal flag; this is the same guard for the counter, read off the transaction the
|
||||
* breakdown belongs to.
|
||||
*
|
||||
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD
|
||||
* register keys its rows by its own request id, not a savings transaction -- is kept rather than
|
||||
* silently dropped.
|
||||
* <p>
|
||||
* Two things are excluded, and they are different failures:
|
||||
*
|
||||
* <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 ("
|
||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) ";
|
||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
||||
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
||||
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
||||
//
|
||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id --
|
||||
// is untouched by this test and still handled by the id-magnitude test above.
|
||||
+ "AND NOT EXISTS ("
|
||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
||||
+ " WHERE mt.id = std.txn_id"
|
||||
+ " AND mt.amount <> (SELECT SUM(sd.count * sd.denomination_value)"
|
||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) ";
|
||||
|
||||
/**
|
||||
* Day book — every voucher raised on one date, in one list, newest first.
|
||||
@@ -150,7 +181,12 @@ public class CashReportApiResource {
|
||||
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
||||
// step to reach.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
||||
SELECT r.voucher_no,
|
||||
-- A closure is paid out at the counter, so it is the cashier's work.
|
||||
-- This column was missing entirely and the row mapper reads it unconditionally,
|
||||
-- so every CLOSURE row threw instead of appearing.
|
||||
'CASHIER' AS section,
|
||||
'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
||||
'SAVINGS' AS account_type, r.client_name AS party,
|
||||
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
||||
r.payment_type_name, r.description, r.status,
|
||||
@@ -210,6 +246,94 @@ public class CashReportApiResource {
|
||||
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
|
||||
""", this::mapDayBookRow, txnDate));
|
||||
|
||||
// Everything else that moved money on this date and is not one of the registers above:
|
||||
// interest posted, a charge collected from a balance, a counter deposit or withdrawal, an
|
||||
// escheat, a manual journal.
|
||||
//
|
||||
// Read from the voucher register rather than from another set of source tables, because
|
||||
// that is the one place every posting lands whatever raised it — including the nightly jobs,
|
||||
// which have no request row anywhere to read them from. Without this the officer's sheet
|
||||
// showed only what somebody had filed a request for, and a day of system postings against
|
||||
// members' accounts was invisible on the very page meant to account for the day.
|
||||
//
|
||||
// The registers above are excluded by reference type, so a voucher already listed as a
|
||||
// deposit or a withdrawal request cannot appear twice under a different heading.
|
||||
//
|
||||
// Status is reported as COMPLETED: a voucher only exists once its posting has committed, so
|
||||
// there is no pending state to represent — unlike a request, which can sit unactioned.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT v.voucher_no,
|
||||
-- A nightly batch belongs to the day it worked on, not the minute it ran.
|
||||
--
|
||||
-- A voucher's created_at is a wall-clock instant, while the transaction it
|
||||
-- posts and the journal entry behind it both carry the business date. So the
|
||||
-- interest job that fires at 00:10 stamps its vouchers with tomorrow while
|
||||
-- the ledger says yesterday — and the day it actually belongs to had already
|
||||
-- been counted, reported and signed off hours earlier.
|
||||
--
|
||||
-- Rather than re-open a closed day, such a voucher is listed on the day it
|
||||
-- surfaces, under its own heading, saying which day's work it is. The date is
|
||||
-- taken from the transaction it posts; where there is none — a manual journal
|
||||
-- keyed straight to the ledger — an early-hours timestamp is treated the same
|
||||
-- way, because nobody is at a counter at 3am.
|
||||
CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
|
||||
< CAST(v.created_at AS DATE)
|
||||
OR (v.savings_txn_id IS NULL
|
||||
AND EXTRACT(HOUR FROM v.created_at) < 6)
|
||||
THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section,
|
||||
-- Which day's work this is, for the heading on the prior-day block.
|
||||
COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date,
|
||||
CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side,
|
||||
v.voucher_type AS kind,
|
||||
-- The member's own account number, never a voucher line's — a line carries a
|
||||
-- GL code there, and showing 500201 in an Account column reads as an account
|
||||
-- that does not exist. A journal with no member behind it shows none.
|
||||
sa.account_no,
|
||||
NULL AS account_type,
|
||||
COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party,
|
||||
v.amount,
|
||||
NULL AS payment_type_name,
|
||||
-- The two sides in one phrase, which is what a journal voucher is read as.
|
||||
CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description,
|
||||
'COMPLETED' AS status,
|
||||
v.created_by_username AS handled_by,
|
||||
-- Who checked it, if anybody has. Not aliased from verifyCols below, which
|
||||
-- selects verified_by_name under its own name for the verification panel --
|
||||
-- the row mapper reads both columns, so each has to be present exactly once.
|
||||
vv.verified_by_name AS approved_by,
|
||||
CAST(v.savings_txn_id AS VARCHAR) AS txn_id,
|
||||
v.created_at AS submitted_at, v.created_at AS actioned_at,
|
||||
""" + verifyCols + """
|
||||
FROM m_voucher v
|
||||
-- Either link: savings_txn_id is stamped by a step after the voucher is written
|
||||
-- and is empty on every interest posting, where reference_id already carries the
|
||||
-- same transaction id. Without the fallback the prior-day test never fires on the
|
||||
-- nightly postings it was written for.
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.id = COALESCE(v.savings_txn_id,
|
||||
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
|
||||
THEN v.reference_id END)
|
||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT party_name, account_no FROM m_voucher_line
|
||||
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
|
||||
ORDER BY line_no LIMIT 1) dr ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT party_name, account_no FROM m_voucher_line
|
||||
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no
|
||||
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
||||
va ON va.voucher_no = v.voucher_no
|
||||
WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE)
|
||||
AND (v.reference_type IS NULL OR v.reference_type NOT IN (
|
||||
'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST',
|
||||
'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST',
|
||||
'SPONSOR_DD_ISSUE_REQUEST'))
|
||||
""", this::mapDayBookRow, txnDate));
|
||||
|
||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
||||
// ordered as one result set, and the row count for a single day is small.
|
||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
||||
@@ -218,6 +342,10 @@ public class CashReportApiResource {
|
||||
final List<Map<String, Object>> filtered = new ArrayList<>();
|
||||
BigDecimal creditTotal = BigDecimal.ZERO;
|
||||
BigDecimal debitTotal = BigDecimal.ZERO;
|
||||
// Kept apart from the day's own totals so a reader can see the overnight batch without it
|
||||
// silently joining figures that were already reported and signed.
|
||||
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
||||
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||
@@ -236,8 +364,12 @@ public class CashReportApiResource {
|
||||
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
||||
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
||||
final String rowStatus = (String) row.get("status");
|
||||
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
|
||||
// A batch that posted against yesterday is listed today but is not today's money: it
|
||||
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
||||
// it to this day's totals would make them disagree with the ledger for either date.
|
||||
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
||||
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
if (amount != null) {
|
||||
if ("CR".equals(row.get("side"))) {
|
||||
@@ -247,6 +379,16 @@ public class CashReportApiResource {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (priorDay) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
if (amount != null) {
|
||||
if ("CR".equals(row.get("side"))) {
|
||||
priorCreditTotal = priorCreditTotal.add(amount);
|
||||
} else {
|
||||
priorDebitTotal = priorDebitTotal.add(amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered.sort((a, b) -> {
|
||||
@@ -272,9 +414,28 @@ public class CashReportApiResource {
|
||||
result.put("creditTotal", creditTotal);
|
||||
result.put("debitTotal", debitTotal);
|
||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
||||
// The overnight batch, reported separately: this is yesterday's work surfacing today.
|
||||
result.put("priorCreditTotal", priorCreditTotal);
|
||||
result.put("priorDebitTotal", priorDebitTotal);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* A date column that only some of the union'd queries select.
|
||||
*
|
||||
* The six day-book queries do not share a column list, and adding a column to all of them so one
|
||||
* can carry it would mean five queries stating a date they were handed as a parameter. Absent is
|
||||
* reported as null rather than as an error.
|
||||
*/
|
||||
private String optionalDate(final java.sql.ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
return value == null ? null : value.toLocalDate().toString();
|
||||
} catch (final java.sql.SQLException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("voucherNo", rs.getString("voucher_no"));
|
||||
@@ -282,6 +443,12 @@ public class CashReportApiResource {
|
||||
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
|
||||
// where the entry came from, not who typed it.
|
||||
row.put("section", rs.getString("section"));
|
||||
|
||||
// Which day's work the row is: normally the day being reported, but a nightly batch posts
|
||||
// against the previous business date while surfacing on this one. Only the SYSTEM query
|
||||
// selects it, so it is read defensively rather than added to five other queries that would
|
||||
// all report the same date they were asked for.
|
||||
row.put("effectiveDate", optionalDate(rs, "effective_date"));
|
||||
row.put("side", rs.getString("side"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
row.put("accountNo", rs.getString("account_no"));
|
||||
@@ -334,39 +501,93 @@ public class CashReportApiResource {
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
|
||||
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
||||
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
|
||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
||||
// Every note and coin that reached the drawer or left it, per denomination.
|
||||
//
|
||||
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
|
||||
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
|
||||
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
|
||||
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
|
||||
// counted against. So the vault allocation and the settlement back are included on the same
|
||||
// footing as the counter work, and each row says where its notes came from.
|
||||
//
|
||||
// Cashier scoping differs per leg because each table records the party differently: the
|
||||
// counter tables name the acting user, the cash tables name the cashier row.
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
|
||||
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
|
||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
|
||||
+ "FROM ("
|
||||
// Counter work: what customers handed over and were paid.
|
||||
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
|
||||
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
|
||||
+ " FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
||||
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
||||
+ " 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<>();
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
args.add(txnDate);
|
||||
if (scopedCashierId != null) {
|
||||
args.add(scopedCashierId);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
long depositCount = rs.getLong("deposit_count");
|
||||
long withdrawalCount = rs.getLong("withdrawal_count");
|
||||
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||
final long vaultInCount = rs.getLong("vault_in_count");
|
||||
final long depositCount = rs.getLong("deposit_count");
|
||||
final long withdrawalCount = rs.getLong("withdrawal_count");
|
||||
final long settledOutCount = rs.getLong("settled_out_count");
|
||||
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
|
||||
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
|
||||
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", rs.getLong("denomination_id"));
|
||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
row.put("vaultInCount", vaultInCount);
|
||||
row.put("vaultInAmount", vaultInAmount);
|
||||
row.put("depositCount", depositCount);
|
||||
row.put("depositAmount", depositAmount);
|
||||
row.put("withdrawalCount", withdrawalCount);
|
||||
row.put("withdrawalAmount", withdrawalAmount);
|
||||
row.put("netCount", depositCount - withdrawalCount);
|
||||
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
||||
row.put("settledOutCount", settledOutCount);
|
||||
row.put("settledOutAmount", settledOutAmount);
|
||||
// What is left of this denomination: everything that came in, less everything that went
|
||||
// out. This is the figure a teller counts their notes against, per note.
|
||||
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
|
||||
row.put("netAmount",
|
||||
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
|
||||
return row;
|
||||
}, args.toArray());
|
||||
|
||||
@@ -488,10 +709,49 @@ public class CashReportApiResource {
|
||||
result.put("openingBalance", openingBalance);
|
||||
result.put("closingBalance", closingBalance);
|
||||
result.put("cashMovement", closingBalance.subtract(openingBalance));
|
||||
|
||||
// The day on its own terms, which is what a cashier actually reconciles.
|
||||
//
|
||||
// A cashier does not carry a balance: they draw cash from the vault in the morning, work
|
||||
// the counter, and hand it back at close. So the cumulative figures above answer a
|
||||
// question nobody at the counter asks — and one carrying years of history answers it
|
||||
// with a number that cannot be counted against anything on the desk. These four are the
|
||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||
// back. What is left is what should physically be in the drawer.
|
||||
result.put("todayVaultIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todaySettledOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */
|
||||
private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) {
|
||||
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one cashier is holding at the end of a given date, in rupees.
|
||||
*
|
||||
@@ -826,7 +1086,17 @@ public class CashReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private Map<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
|
||||
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 "
|
||||
|
||||
+67
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
@@ -57,6 +58,9 @@ public class CashSettlementApiResource {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final VoucherService voucherService;
|
||||
// The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report
|
||||
// says they are not holding.
|
||||
private final CashReportApiResource cashReportApiResource;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@@ -112,6 +116,8 @@ public class CashSettlementApiResource {
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
|
||||
|
||||
final BigDecimal total = sumTotal(denominations);
|
||||
|
||||
CashSettlement header = new CashSettlement();
|
||||
@@ -290,6 +296,67 @@ public class CashSettlementApiResource {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a hand-over of notes the cashier is not holding.
|
||||
*
|
||||
* <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) {
|
||||
for (JsonElement el : items) {
|
||||
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
||||
|
||||
+27
-30
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
||||
@@ -52,6 +53,9 @@ public class CashTransferApiResource {
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
// The same source the Cash Transfer screen reads its Available column from, so what is offered
|
||||
// and what is checked cannot disagree.
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@@ -327,46 +331,39 @@ public class CashTransferApiResource {
|
||||
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
||||
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
||||
* total is well within its balance. This checks each requested denomination against what the
|
||||
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
|
||||
* settlements-in minus today's transfers-out actually holds of that specific denomination.
|
||||
* vault actually holds of it.
|
||||
*
|
||||
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
|
||||
* denomination against, so this is skipped rather than blocking every transfer on a day that
|
||||
* simply never captured one.
|
||||
* <p>
|
||||
* The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
|
||||
* shows in its Available column. It has to: this check used to walk the register itself and read
|
||||
* the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under
|
||||
* it is holding and is anchored on a different day. The screen offered a ₹100 note the vault
|
||||
* plainly had and the transfer was then refused as "vault has 0" — two answers to one question,
|
||||
* from two sources. Reading the service means the number that was offered is the number that is
|
||||
* checked, and neither can drift from the other again.
|
||||
*
|
||||
* <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,
|
||||
final JsonArray denominations, final java.sql.Date businessDate) {
|
||||
final List<Object[]> openingRows = jdbcTemplate.query("""
|
||||
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
|
||||
WHERE register_id = ? AND leg = 'OPENING'
|
||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
|
||||
if (openingRows.isEmpty()) {
|
||||
final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
|
||||
if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, Integer> available = new java.util.HashMap<>();
|
||||
for (final Object[] row : openingRows) {
|
||||
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||
if (heldRows != null) {
|
||||
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) {
|
||||
available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
|
||||
}
|
||||
|
||||
final List<Object[]> settledRows = jdbcTemplate.query("""
|
||||
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
|
||||
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
||||
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
||||
GROUP BY csd.denomination_id
|
||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
||||
for (final Object[] row : settledRows) {
|
||||
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
|
||||
}
|
||||
|
||||
final List<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) {
|
||||
|
||||
+25
@@ -215,6 +215,31 @@ public class ClerkClosureRequestApiResource {
|
||||
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
|
||||
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
||||
savingsAccountWritePlatformService.postInterest(account, true, closureDate, false);
|
||||
|
||||
// What the account itself says is still owed once that posting has run: everything the
|
||||
// account has earned over its life, less everything already credited to the member. The
|
||||
// account's own summary is the figure the account screen shows, so a closure and that screen
|
||||
// can no longer disagree about what is outstanding.
|
||||
//
|
||||
// Normally this is zero -- the posting above has just credited the shortfall, which is the
|
||||
// whole point of running it. It is not zero when the summary carries interest the posting did
|
||||
// not reach: interest accrued against a period the posting run does not close, most often on
|
||||
// a cycle that has not yet ended. Left alone that interest is simply dropped, because the
|
||||
// account is about to be closed and no later run will ever pay it.
|
||||
//
|
||||
// Only ever pays out what the two totals still differ by, so interest already credited cannot
|
||||
// be paid a second time, and a negative difference (more posted than earned, which a reversal
|
||||
// can leave behind) is ignored rather than taken back off the member here.
|
||||
final BigDecimal earned = zeroIfNull(account.getSummary().getTotalInterestEarned());
|
||||
final BigDecimal posted = zeroIfNull(account.getSummary().getTotalInterestPosted());
|
||||
final BigDecimal stillOwed = earned.subtract(posted);
|
||||
|
||||
if (stillOwed.compareTo(BigDecimal.ZERO) > 0) {
|
||||
LOG.warn("Closure of savings account {}: {} of accrued interest is still unposted after posting up to {} "
|
||||
+ "(earned {} against posted {}). The payout below does not include it.", savingsAccountId, stillOwed,
|
||||
closureDate, earned, posted);
|
||||
}
|
||||
|
||||
return zeroIfNull(account.getSummary().getAccountBalance());
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -56,7 +56,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -77,10 +81,13 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||
@@ -701,9 +708,17 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// shares left for the native close command to force-redeem without oversight.
|
||||
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||
|
||||
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
|
||||
// redemption settled into a member's savings account left nothing on the voucher register to
|
||||
// read it back from. The GL entries were always written — it is the slip that was missing.
|
||||
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (voucherNo != null) {
|
||||
res.addProperty("voucherNo", voucherNo);
|
||||
}
|
||||
if (shareTxnId != null) {
|
||||
res.addProperty("shareTransactionId", shareTxnId);
|
||||
}
|
||||
@@ -742,6 +757,62 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings.
|
||||
*
|
||||
* <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) {}
|
||||
|
||||
/**
|
||||
|
||||
+22
@@ -61,6 +61,16 @@ public class SavingsDailyInterestOrchestrator {
|
||||
ORDER BY s.savings_product_id
|
||||
""");
|
||||
|
||||
// Products that could not be paid, collected so the run can fail loudly at the end.
|
||||
//
|
||||
// Carrying on past one product is right — a mapping missing on one product must not stop
|
||||
// every member of every other from being paid. Reporting the job as successful afterwards is
|
||||
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
|
||||
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
|
||||
// nothing reads. On a night when every product fails, that is a whole day of interest
|
||||
// silently unpaid.
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
|
||||
for (final Map<String, Object> schedule : schedules) {
|
||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||
final Long dueCount = jdbcTemplate.queryForObject("""
|
||||
@@ -80,14 +90,26 @@ public class SavingsDailyInterestOrchestrator {
|
||||
} catch (final RuntimeException e) {
|
||||
// The product rolled back on its own; record why and let the others run.
|
||||
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
||||
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
|
||||
// the row — which is exactly what a product with no GL mapping does. The failure is
|
||||
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE savings_product_id = ? AND posting_date = ?
|
||||
""", truncate(e.getMessage()), productId, businessDate);
|
||||
updateSchedule(productId, businessDate, "FAILED");
|
||||
failures.add("product " + productId + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fails the job so the scheduler records it as failed and an operator is told. Every product
|
||||
// that could be paid has already been paid and committed by this point — this reports what
|
||||
// happened, it does not undo it.
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
||||
+ failures.size() + " product(s): " + String.join("; ", failures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+183
-4
@@ -23,6 +23,7 @@ import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -194,11 +195,19 @@ public class SavingsDailyInterestService {
|
||||
// today's figures. Only a row for a day before today can be carried forward — one already
|
||||
// stamped with today or later means the day has been accrued and re-reading it as "previous"
|
||||
// would compound the same day's interest onto itself.
|
||||
// Newest first, one row. The table currently carries a unique constraint on
|
||||
// savings_account_id so at most one row can match and the ordering is academic — but the
|
||||
// code below reads previous.get(0) as "yesterday", and the moment per-day history is
|
||||
// restored an unordered query would hand it an arbitrary earlier day as the opening balance
|
||||
// and silently break the chain. Stating the intent costs nothing and does not depend on the
|
||||
// constraint staying where it is.
|
||||
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
||||
cycle_start_date, cycle_end_date, is_posting_day
|
||||
FROM m_savings_daily_interest
|
||||
WHERE savings_account_id = ? AND balance_date < ?
|
||||
ORDER BY balance_date DESC
|
||||
LIMIT 1
|
||||
""", accountId, businessDate);
|
||||
|
||||
final BigDecimal openingBalance;
|
||||
@@ -208,8 +217,6 @@ public class SavingsDailyInterestService {
|
||||
final LocalDate cycleEnd;
|
||||
|
||||
if (previous.isEmpty()) {
|
||||
openingBalance = BigDecimal.ZERO;
|
||||
carriedInterest = BigDecimal.ZERO;
|
||||
cycleNumber = 1;
|
||||
// The first row belongs to the cycle the day it records falls in, and starts at
|
||||
// activation only when that day is still inside the account's opening cycle. Anchoring
|
||||
@@ -221,6 +228,19 @@ public class SavingsDailyInterestService {
|
||||
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
|
||||
financialYearBeginningMonth);
|
||||
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
|
||||
// The account has earned since its cycle opened, but the ledger only starts today —
|
||||
// either because the account predates this ledger, or because the nightly run has not
|
||||
// reached it before now. Taking the opening balance as zero and the carried interest as
|
||||
// nothing would credit the member for today alone and silently drop every day since
|
||||
// cycleStart, which is exactly what a closure then pays out.
|
||||
//
|
||||
// So the days already gone are replayed here, from the account's own transactions, to
|
||||
// arrive at the balance and the running interest the ledger would be holding had it
|
||||
// been running all along.
|
||||
final Backfill backfill = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||
minBalance, averageDailyBalanceOf(calcType));
|
||||
openingBalance = backfill.closingBalance();
|
||||
carriedInterest = backfill.cumulativeInterest();
|
||||
} else {
|
||||
final Map<String, Object> prev = previous.get(0);
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
@@ -249,8 +269,7 @@ public class SavingsDailyInterestService {
|
||||
|
||||
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
|
||||
// whichever balance the account actually earns on, which is not always today's closing.
|
||||
final boolean averageDailyBalance = calcType != null
|
||||
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
|
||||
final boolean averageDailyBalance = averageDailyBalanceOf(calcType);
|
||||
|
||||
final BigDecimal dailyInterest;
|
||||
final BigDecimal cumulative;
|
||||
@@ -389,6 +408,166 @@ public class SavingsDailyInterestService {
|
||||
* It cannot double-count, because a posting only ever falls on one day and the row for that day
|
||||
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
|
||||
*/
|
||||
/**
|
||||
* Brings one account's ledger up to the business date after its balance has moved.
|
||||
*
|
||||
* <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) {
|
||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
|
||||
+66
-40
@@ -41,19 +41,23 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* holds the figure that would say so.
|
||||
*
|
||||
* This posts the product's total first, into the liability the product names as its Savings Interest
|
||||
* Payable, and only then pays it out to members:
|
||||
* Payable, moves it to the account it is paid out of, and only then pays it to members:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Interest Paid / Interest On Savings (expense, from the product)
|
||||
* Cr Savings Interest Payable (liability, from the product)
|
||||
* Dr Interest On Savings (expense, from the product)
|
||||
* Cr Savings Interest Payable (liability, from the product) what the bank now owes
|
||||
*
|
||||
* Dr Savings Interest Payable (per member)
|
||||
* Cr Savings Control (per member, from the product)
|
||||
* Dr Savings Interest Payable
|
||||
* Cr Savings Interest Paid on its way out
|
||||
*
|
||||
* Dr Savings Interest Paid (per member)
|
||||
* Cr Savings Control (per member, from the product) in the member's hands
|
||||
* </pre>
|
||||
*
|
||||
* Every account named above is read from the product's own GL configuration; none is written into
|
||||
* this class. A product that configures no payable falls back to the two seeded control accounts,
|
||||
* which keeps products predating that mapping posting exactly as before.
|
||||
* this class. A product that names no paid account collapses the middle step - both legs resolve to
|
||||
* the payable - and posts the two-legged entry it always did; one that names no payable falls back
|
||||
* to its own Savings Control, since interest owed is a liability either way.
|
||||
*
|
||||
* The whole product settles inside one transaction, and the payable has to read zero before it is
|
||||
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
|
||||
@@ -75,22 +79,21 @@ public class SavingsPooledInterestPostingService {
|
||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||
|
||||
/**
|
||||
* Reserved names in the product's own GL mappings, which is where the two legs of the interest
|
||||
* bill are configured. The names are the contract between this service and the product screen —
|
||||
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account.
|
||||
* Reserved names in the product's own GL mappings, which is where the middle two legs of the
|
||||
* interest bill are configured. The names are the contract between this service and the product
|
||||
* screen — the GL codes behind them are the bank's choice, not ours, so nothing here names an
|
||||
* account. A product that leaves one unset falls back to another mapping the same product
|
||||
* carries; no GL code is ever written into this file.
|
||||
*/
|
||||
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
|
||||
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
|
||||
|
||||
/** Fallback control accounts, used only where the product leaves the mappings unset. */
|
||||
private static final String GL_POSTING_CONTROL = "200304";
|
||||
private static final String GL_POSTED_CONTROL = "200305";
|
||||
|
||||
/** m_savings_account_transaction.transaction_type_enum */
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@@ -164,33 +167,39 @@ public class SavingsPooledInterestPostingService {
|
||||
largest.get("savings_account_id"));
|
||||
}
|
||||
|
||||
// The expense the bill is recognised against, and the liability it sits in until it reaches
|
||||
// the members. Both come from the product: "Savings Interest Paid" if the product names one,
|
||||
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the
|
||||
// liability, falling back to the control accounts only where a product configures neither.
|
||||
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID),
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS));
|
||||
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE);
|
||||
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL));
|
||||
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL);
|
||||
// The three accounts the bill passes through on its way to the members. Every one is read
|
||||
// from the product's own configuration — the standard Interest On Savings mapping, and the
|
||||
// two reserved names on the product's Accounting tab — so a bank on its own chart of
|
||||
// accounts is posted to the accounts it chose. No GL code is named in this class.
|
||||
//
|
||||
// Where a product leaves a reserved name unset the bill still has somewhere to go: the
|
||||
// payable falls back to the product's Savings Control (interest owed is a liability either
|
||||
// way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and
|
||||
// leaves the two-legged posting a product without these mappings has always had.
|
||||
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
|
||||
final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
||||
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
||||
|
||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||
final String currency = (String) due.get(0).get("currency_code");
|
||||
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||
|
||||
// Stage 1: the product's whole interest bill, recognised and parked in the payable.
|
||||
// Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's
|
||||
// interest is parked in the payable, where it stands as a debt to the members until it
|
||||
// reaches them.
|
||||
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null);
|
||||
|
||||
// Stage 2: the pool moves to the account it is paid out of.
|
||||
// Stage 2: the debt moves to the account the payout is made from, so what is still merely
|
||||
// owed and what is on its way out are two separate balances rather than one.
|
||||
//
|
||||
// Where the product names a single payable, both control legs resolve to it and this stage
|
||||
// would debit and credit the same account for the same amount — a pair of entries that say
|
||||
// nothing and clutter the ledger. Only a product still on the two distinct control accounts
|
||||
// needs the transfer written.
|
||||
if (!postingControlGl.equals(postedControlGl)) {
|
||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
||||
// Skipped where the product names no separate paid account: both legs then resolve to the
|
||||
// payable and this would debit and credit the same account for the same amount — a pair of
|
||||
// entries that say nothing and clutter the ledger.
|
||||
if (!payableGl.equals(paidGl)) {
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
||||
}
|
||||
|
||||
// Stage 3: out to the members, one at a time.
|
||||
@@ -208,7 +217,7 @@ public class SavingsPooledInterestPostingService {
|
||||
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||
newBalance);
|
||||
|
||||
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference,
|
||||
journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference,
|
||||
accountId);
|
||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||
accountId, transactionId);
|
||||
@@ -309,7 +318,14 @@ public class SavingsPooledInterestPostingService {
|
||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount,
|
||||
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares
|
||||
// CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way
|
||||
// round: the member's savings-control credit was stored as a debit and the expense
|
||||
// debit as a credit. Nothing downstream caught it — the run's own pooled-equals-
|
||||
// distributed check compares Java values and never looks at type_enum, and migration
|
||||
// 0531 disables the stock posting job, so this is the only path writing savings
|
||||
// interest to the ledger and there was no correct one alongside to disagree with it.
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
|
||||
debit ? "Savings interest posting" : "Savings interest posted",
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||
systemUserId(), systemUserId(), date);
|
||||
@@ -325,16 +341,26 @@ public class SavingsPooledInterestPostingService {
|
||||
return ids.isEmpty() ? 1L : ids.get(0);
|
||||
}
|
||||
|
||||
private Long glAccountId(final String glCode) {
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account the product maps under one of Fineract's fixed financial account types, as set
|
||||
* on the product's Accounting tab.
|
||||
*
|
||||
* <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) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl_account_id FROM acc_product_mapping
|
||||
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||
LIMIT 1
|
||||
""", Long.class, productId, financialAccountType);
|
||||
if (found.isEmpty()) {
|
||||
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
|
||||
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
|
||||
}
|
||||
return found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+34
-1
@@ -43,6 +43,8 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
|
||||
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -59,6 +61,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||
private final VoucherService voucherService;
|
||||
|
||||
@Autowired
|
||||
public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository,
|
||||
@@ -67,7 +71,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
||||
final JournalEntryWritePlatformService journalEntryWritePlatformService,
|
||||
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
|
||||
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||
final BusinessEventNotifierService businessEventNotifierService) {
|
||||
final BusinessEventNotifierService businessEventNotifierService,
|
||||
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) {
|
||||
this.savingsAccountRepository = savingsAccountRepository;
|
||||
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
|
||||
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
|
||||
@@ -76,6 +81,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
||||
this.context = context;
|
||||
this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository;
|
||||
this.businessEventNotifierService = businessEventNotifierService;
|
||||
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||
this.voucherService = voucherService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -148,6 +155,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
||||
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
|
||||
}
|
||||
|
||||
// As on the deposit path: the ledger's row must follow the balance out, or the account goes on
|
||||
// earning on money it no longer holds until tonight's run corrects it.
|
||||
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
|
||||
|
||||
// The debit voucher for the withdrawal, including any withdrawal fee the posting collected —
|
||||
// one slip, every leg, the way a payment voucher is written at a counter.
|
||||
if (!transactionBooleanValues.isSkipAccountingPostings()) {
|
||||
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||
withdrawal.getId(), context.authenticatedUser(), VoucherService.PREFIX_DEBIT);
|
||||
}
|
||||
|
||||
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
|
||||
return withdrawal;
|
||||
}
|
||||
@@ -238,6 +256,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
||||
if (!skipAccountingPostings) {
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
|
||||
}
|
||||
// The daily-interest ledger states the balance as at its last row, which is yesterday's until
|
||||
// tonight's run. A closure raised in between reads that row, so money paid in today would earn
|
||||
// nothing and money paid out would go on earning. Bringing the row up to date here keeps what
|
||||
// a closure reads in step with what the account holds.
|
||||
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
|
||||
|
||||
// The credit voucher for the deposit. Raised from the journal entries the posting just wrote,
|
||||
// so a deposit that also collected a charge carries every leg on ONE slip — which is what the
|
||||
// member signs and the branch files. This is the path every counter deposit takes (cashier,
|
||||
// clerk, transfer in), and it raised no voucher at all before.
|
||||
if (!skipAccountingPostings) {
|
||||
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_DEPOSIT, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||
deposit.getId(), context.authenticatedUser(), VoucherService.PREFIX_CREDIT);
|
||||
}
|
||||
|
||||
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
|
||||
return deposit;
|
||||
}
|
||||
|
||||
+28
-38
@@ -363,9 +363,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
this.noteRepository.save(note);
|
||||
}
|
||||
|
||||
// Money arriving at the counter: cash is debited and the member's savings control account
|
||||
// is credited.
|
||||
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true);
|
||||
// The voucher itself is raised by handleDeposit() above, off the journal entries it posted —
|
||||
// one slip covering every leg, including any charge collected with the deposit. Read it back
|
||||
// here only to return it to the caller.
|
||||
final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId());
|
||||
if (depositVoucherNo != null) {
|
||||
changes.put("voucherNo", depositVoucherNo);
|
||||
}
|
||||
@@ -446,9 +447,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
this.noteRepository.save(note);
|
||||
}
|
||||
|
||||
// Money leaving the member over the counter: their savings control account is debited and
|
||||
// the till that pays it out is credited.
|
||||
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
|
||||
// As on the deposit path: handleWithdrawal() raised the slip; this only reads it back.
|
||||
final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId());
|
||||
if (withdrawalVoucherNo != null) {
|
||||
changes.put("voucherNo", withdrawalVoucherNo);
|
||||
}
|
||||
@@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||
|
||||
// A charge taken from the balance is a journal voucher, not a debit voucher: no cash crosses
|
||||
// the counter, the member's balance simply moves to an income head. A branch files these in
|
||||
// the JV book, which is why the series differs from a counter withdrawal that collects a fee.
|
||||
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_CHARGE, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||
chargeTransaction.getId(), this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL);
|
||||
|
||||
return chargeTransaction;
|
||||
}
|
||||
|
||||
@@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
account.escheat(appuserRepository.fetchSystemUser());
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
||||
|
||||
// A journal voucher for the balance moved to the escheat liability. Raised under the system
|
||||
// user, not a branch user: this runs from the dormant-accounts batch with nobody at a
|
||||
// counter, and the register has to say so.
|
||||
final AppUser systemUser = appuserRepository.fetchSystemUser();
|
||||
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
|
||||
if (transaction.getId() != null && !existingTransactionIds.contains(transaction.getId())
|
||||
&& transaction.isAmountOnHoldNotReleased() == false && transaction.isNotReversed()) {
|
||||
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_ESCHEAT, REF_SAVINGS_TRANSACTION,
|
||||
transaction.getId(), systemUser, VoucherService.PREFIX_JOURNAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AppUser getAppUserIfPresent() {
|
||||
@@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
}
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
||||
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT,
|
||||
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
|
||||
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
||||
.withDescription("Interest posted to " + accountNo),
|
||||
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
||||
.withDescription("Interest credited")));
|
||||
// Without this the voucher exists but nothing ties it to the transaction it paid, so
|
||||
// the register cannot show the two together and the transaction shows no voucher_no.
|
||||
this.voucherService.linkSavingsTransaction(REF_SAVINGS_TRANSACTION, transaction.getId(), transaction.getId());
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount,
|
||||
final boolean isDeposit) {
|
||||
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Map<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
-1
@@ -435,7 +435,14 @@ public class VoucherVerificationApiResource {
|
||||
@Path("report")
|
||||
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
|
||||
@QueryParam("cashOnly") final Boolean cashOnly) {
|
||||
@QueryParam("cashOnly") final Boolean cashOnly,
|
||||
@QueryParam("cashiersOnly") final Boolean cashiersOnlyParam,
|
||||
@QueryParam("systemOnly") final Boolean systemOnlyParam) {
|
||||
|
||||
// Defaults to false so an existing caller that does not send the flag keeps seeing the whole
|
||||
// register; the two counter screens opt in.
|
||||
final boolean cashiersOnly = Boolean.TRUE.equals(cashiersOnlyParam);
|
||||
final boolean systemOnly = Boolean.TRUE.equals(systemOnlyParam);
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final boolean seesEveryone = isCheckerRole(currentUser);
|
||||
@@ -457,6 +464,15 @@ public class VoucherVerificationApiResource {
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
||||
-- The date the posting actually belongs to, which is not always the date the
|
||||
-- voucher was written. A voucher's created_at is a wall-clock instant, while
|
||||
-- the transaction behind it carries the business date — so a nightly run that
|
||||
-- finishes after midnight stamps its vouchers with the next day although the
|
||||
-- ledger sits on the day it worked on. Somebody reading this register today
|
||||
-- is looking at last night's work, and needs to be told which day it is for.
|
||||
COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) AS ledger_date,
|
||||
(COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
|
||||
< CAST(v.created_at AS DATE)) AS posted_after_close,
|
||||
v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
|
||||
ver.status AS verification_status,
|
||||
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
||||
@@ -465,6 +481,17 @@ public class VoucherVerificationApiResource {
|
||||
cash.moved AS is_cash
|
||||
FROM m_voucher v
|
||||
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||
-- Carries the business date the posting belongs to, for ledger_date above.
|
||||
--
|
||||
-- Matched on either link. savings_txn_id is stamped by a second step after the
|
||||
-- voucher is written, and on the interest register it is empty on every row --
|
||||
-- reference_id already holds the same transaction id, put there when the voucher
|
||||
-- was raised. Joining only on savings_txn_id left the ledger date blank on
|
||||
-- precisely the postings this column exists for.
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.id = COALESCE(v.savings_txn_id,
|
||||
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
|
||||
THEN v.reference_id END)
|
||||
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT party_name, account_no FROM m_voucher_line
|
||||
@@ -537,6 +564,38 @@ public class VoucherVerificationApiResource {
|
||||
sql.append(" AND v.voucher_type = ?");
|
||||
args.add(voucherType.trim());
|
||||
}
|
||||
// Narrowed to the people who hold a drawer.
|
||||
//
|
||||
// These two screens are the counter's own books: what came in over it and what went out.
|
||||
// Everyone else's work — a clerk raising a request, a manager approving one, interest a
|
||||
// nightly run posted — is their own to account for, and each person reads it on My Vouchers.
|
||||
// Mixed in here it buried the cashier rows these registers exist to reconcile, and left the
|
||||
// totals disagreeing with any drawer.
|
||||
//
|
||||
// Read from m_cashiers rather than from a role name, because holding a drawer is what makes
|
||||
// somebody a cashier — a role called "Cashier" can be granted to anybody, and somebody
|
||||
// covering a drawer without that role would otherwise vanish from the register they are
|
||||
// answerable for. A username is matched through the staff record the drawer belongs to.
|
||||
if (cashiersOnly) {
|
||||
sql.append(" AND v.created_by_username IN (SELECT u.username FROM m_cashiers c"
|
||||
+ " JOIN m_appuser u ON u.staff_id = c.staff_id WHERE u.username IS NOT NULL)");
|
||||
}
|
||||
// Postings nobody filed a request for: interest a nightly run credited, a charge collected
|
||||
// from a balance, an escheat, a journal keyed straight to the ledger.
|
||||
//
|
||||
// Identified by what they are not — none of the five registers a person raises through. A
|
||||
// reference type is stamped at post time and cannot be granted or revoked the way a role
|
||||
// can, so this holds even when a job runs as somebody's own login rather than as `system`.
|
||||
//
|
||||
// These have no home on any other screen: My Vouchers is scoped to the reader's own work
|
||||
// and a nightly job is nobody's, and the two counter registers deliberately exclude
|
||||
// everything that did not cross a drawer. Without this filter the only way to see what the
|
||||
// system posted was to read the whole register and discount the rest by eye.
|
||||
if (systemOnly) {
|
||||
sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN ("
|
||||
+ "'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', 'CLERK_CLOSURE_REQUEST',"
|
||||
+ " 'CLERK_DISBURSEMENT_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST'))");
|
||||
}
|
||||
// A cashier's register is cash: what came over the counter in notes and coins.
|
||||
//
|
||||
// The test is whether a side of the voucher is the till (the `till` join above). That is
|
||||
@@ -580,6 +639,11 @@ public class VoucherVerificationApiResource {
|
||||
m.put("voucherType", r.get("voucher_type"));
|
||||
m.put("amount", amount);
|
||||
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||
// The day the posting belongs to, and whether that differs from the day it was written.
|
||||
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
|
||||
// work: the register has to say so, or the reader takes the timestamp for the date.
|
||||
m.put("ledgerDate", r.get("ledger_date") == null ? null : r.get("ledger_date").toString());
|
||||
m.put("postedAfterClose", Boolean.TRUE.equals(r.get("posted_after_close")));
|
||||
m.put("userId", r.get("created_by"));
|
||||
m.put("username", r.get("created_by_username"));
|
||||
m.put("branchName", r.get("branch_name"));
|
||||
@@ -607,12 +671,212 @@ public class VoucherVerificationApiResource {
|
||||
// from the caller's roles a second time.
|
||||
res.put("canSeeAllUsers", seesEveryone);
|
||||
res.put("cashOnly", restrictToCash);
|
||||
// So the screen can say whose register it is showing rather than leaving the reader to
|
||||
// wonder why their own entries are absent from it.
|
||||
res.put("cashiersOnly", cashiersOnly);
|
||||
res.put("vouchers", out);
|
||||
res.put("count", out.size());
|
||||
res.put("totalAmount", total);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* One user's day: everything they raised and everything they verified, receipts and payments
|
||||
* together.
|
||||
*
|
||||
* <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. */
|
||||
private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
|
||||
+129
@@ -24,11 +24,14 @@ import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -57,6 +60,8 @@ import org.springframework.stereotype.Service;
|
||||
@RequiredArgsConstructor
|
||||
public class VoucherService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class);
|
||||
|
||||
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
||||
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
||||
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
||||
@@ -66,6 +71,8 @@ public class VoucherService {
|
||||
public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
|
||||
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
||||
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
||||
/** A share redemption approved through the clerk register, keyed by the request id. */
|
||||
public static final String REF_CLERK_SHARE_WITHDRAWAL_REQUEST = "CLERK_SHARE_WITHDRAWAL_REQUEST";
|
||||
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
|
||||
|
||||
/** A voucher raised directly as a journal, rather than by one of the cash registers. */
|
||||
@@ -87,12 +94,20 @@ public class VoucherService {
|
||||
*/
|
||||
public static final String PREFIX_JOURNAL = "JV";
|
||||
|
||||
/** A charge or fee collected from an account. */
|
||||
public static final String TYPE_CHARGE = "CHARGE";
|
||||
/** An unclaimed balance moved to the escheat liability. */
|
||||
public static final String TYPE_ESCHEAT = "ESCHEAT";
|
||||
|
||||
/** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
|
||||
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
|
||||
|
||||
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
|
||||
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY";
|
||||
|
||||
/** A savings movement, keyed by its m_savings_account_transaction id. */
|
||||
public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
|
||||
|
||||
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
@@ -254,6 +269,120 @@ public class VoucherService {
|
||||
return voucherNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises one voucher for one savings transaction, reading its sides from the journal entries the
|
||||
* posting has already written.
|
||||
*
|
||||
* <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.
|
||||
*
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -595,4 +595,7 @@
|
||||
<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/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>
|
||||
|
||||
+105
@@ -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>
|
||||
+75
@@ -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 < CURRENT_DATE;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+113
@@ -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 < CURRENT_DATE;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user