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