Compare commits

...
4 Commits
Author SHA1 Message Date
nitesh af43944699 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 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 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 - MariaDB / test (test-core-1) (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 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 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
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-14 12:17:01 +05:30
niteshandClaude Sonnet 5 6bb512e1fe fix(cash): recognize GL-entry cash correctly in reconciliation and reports
- Add CountedCashLedger: one shared rule for which m_savings_txn_denomination
  rows are real counted cash. Replaces a magnitude-only guard (which had
  itself been silently excluding legitimate manual-GL-entry cash from every
  cash screen) with a date-plausibility test -- a stamped garbage id decodes
  back to the day it was written, a real GL reference to an arbitrary date --
  so a hex reference that happens to land in the old guard's excluded band is
  no longer misclassified as timestamp garbage
- Apply it consistently in the day register close preview, the cashier
  position/day-book reports, the cash reconciliation screen, and the
  withdrawal stock check, so all four agree on the same drawer
- GL Entries report: join m_voucher_line to show both legs of each voucher
  (debit and credit account) instead of only the cash leg its own
  denomination row happens to carry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:19:05 +05:30
niteshandClaude Sonnet 5 04ac233fbe fix: share collection GL lookup, tenant chart-of-accounts cleanup, and dormancy/date fixes
- Add balancesheet API, GL account transaction guard, and disabled-feature filter
- Recognize an approved Clerk Disbursement Request (COLLECTION direction) as a
  second valid source when resolving a Share Advance Collection voucher, not
  just a Cashier Transaction Entry deposit
- Fix a NullPointerException in ShareAccount.getShareAccountTransaction() when
  an existing charge transaction has no total-shares/purchase-price recorded
- Null out DormancyClock's "last live" date before an account is ever
  activated, instead of falling through to the client's own KYC re-verification
  date via Postgres's null-skipping GREATEST()
- Stop seeding "Cash at Tellers" for new tenants (0502); drop the unused
  Facility & Service Charges Income accounts (0548); group header-less GL
  accounts under real-world header accounts (0549); uppercase GL account
  names and rename Share Sundry to match the bank's own chart (0550)
- Fix 0527's savings-daily-interest changeset to skip objects 0517 already
  creates on a fresh tenant, instead of failing tenant startup
- Update the local Postgres dev password default to match this environment

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:22:07 +05:30
nitesh 9e0c04583d fix(roles): remove unused roles, merge managers, revoke Clerk's ALL_FUNCTIONS
Audited role usage/permissions across all tenants and cleaned up several
inconsistencies:

- Removed 'Self Service User' (zero users, no wired-up purpose in the app).
- Merged 'As Manager' into 'Branch Manager' (union of both permission sets,
  users reassigned, As Manager dropped) since the two covered overlapping
  ground with no clear split.
- Revoked Clerk's ALL_FUNCTIONS grant: it unconditionally bypassed every
  individual permission check (HasPermissionDirective), so unticking any
  menu/permission for Clerk in Roles & Permissions had no effect. Clerk's
  own explicit permission rows are untouched and remain the effective set.
- Renamed 'CASHIER' to 'Cashier' for display consistency with every other
  role name; the role-name check this touches (CashierRoleHelper) is
  case-insensitive, so behavior is unchanged.

('Principal Officer' was audited too but kept - it's the statutory
PMLA/RBI compliance seat from 0435, just not yet assigned to anyone.)

Each change was applied directly to all three tenant DBs and captured as
a migration (0543, 0546, 0547), dry-run verified as a no-op against the
now-updated DBs so it applies cleanly here and correctly for any new tenant.
2026-09-12 13:54:56 +05:30
36 changed files with 1312 additions and 69 deletions
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, // DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
GL_ACCOUNT_DISABLED, // GL_ACCOUNT_DISABLED, //
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, // GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
GL_ACCOUNT_IS_HEADER, //
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; // INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
public String errorMessage() { public String errorMessage() {
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "Invalid debit or credit accounts are passed"; return "Invalid debit or credit accounts are passed";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
return "Target account does not allow manual adjustments"; return "Target account does not allow manual adjustments";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
} }
return name(); return name();
} }
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts"; return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted"; return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
return "error.msg.glJournalEntry.invalid.account.is.header";
} }
return name(); return name();
} }
@@ -0,0 +1,243 @@
/**
* 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.accounting.balancesheet.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* An "as on date" balance sheet built directly off the live chart of accounts and journal entries,
* rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise
* layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit
* Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header)
* children indented underneath it and a subtotal on the block itself. A Detail account with no
* Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone)
* still needs to appear somewhere, so it is listed as its own root-level line rather than silently
* dropped.
*
* A Header account never itself carries a journal entry (see the header/detail discussion this
* feature grew out of), so its balance is always the recursive sum of its children - never read off
* acc_gl_journal_entry directly.
*/
@Path("/v1/accounting/balancesheet")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BalanceSheetApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
// classification_enum values (see acc_gl_account / GLAccountType).
private static final int ASSET = 1;
private static final int LIABILITY = 2;
private static final int EQUITY = 3;
private static final int INCOME = 4;
private static final int EXPENSE = 5;
// account_usage values (see GLAccountUsage).
private static final int DETAIL = 1;
private static final int HEADER = 2;
private static final class Node {
Long id;
String name;
String glCode;
int usage;
int classification;
Long parentId;
BigDecimal ownBalance = BigDecimal.ZERO;
final List<Node> children = new ArrayList<>();
BigDecimal balance() {
if (usage == DETAIL) {
return ownBalance;
}
BigDecimal sum = BigDecimal.ZERO;
for (final Node child : children) {
sum = sum.add(child.balance());
}
return sum;
}
Map<String, Object> toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("id", id);
json.put("name", name);
json.put("glCode", glCode);
json.put("usage", usage == HEADER ? "HEADER" : "DETAIL");
json.put("balance", balance());
final List<Map<String, Object>> childJson = new ArrayList<>();
for (final Node child : children) {
childJson.add(child.toJson());
}
json.put("children", childJson);
return json;
}
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(asOnDateParam);
final Map<Long, Node> nodesById = new LinkedHashMap<>();
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(
"SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account "
+ "WHERE disabled = false ORDER BY gl_code")) {
final Node node = new Node();
node.id = ((Number) row.get("id")).longValue();
node.name = (String) row.get("name");
node.glCode = (String) row.get("gl_code");
node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue();
node.usage = ((Number) row.get("account_usage")).intValue();
node.classification = ((Number) row.get("classification_enum")).intValue();
nodesById.put(node.id, node);
}
// entry_date (not transaction_date) and "reversed = false" are this codebase's settled
// convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend
// reports and IncomeProfitTransferService all cut the same way, so this report agrees with
// them rather than quietly producing a second set of numbers.
//
// The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This
// codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the
// flag on the original leg (no counter entry - excluding it is exactly right), while
// Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and
// links it back through reversal_id. For that second kind, dropping only the original would
// leave its counter entry standing alone and swing the balance by the full amount in the
// wrong direction, so the counter entry has to go too.
final Map<Long, BigDecimal[]> balances = new LinkedHashMap<>();
final String balanceSql = "SELECT account_id, "
+ "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, "
+ "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit "
+ "FROM acc_gl_journal_entry WHERE entry_date <= ? "
+ "AND (reversed IS NULL OR reversed = false) "
+ "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)"
+ (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id";
final List<Map<String, Object>> balanceRows = officeId != null
? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId)
: this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate));
for (final Map<String, Object> row : balanceRows) {
balances.put(((Number) row.get("account_id")).longValue(),
new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") });
}
for (final Node node : nodesById.values()) {
if (node.usage != DETAIL) {
continue;
}
final BigDecimal[] debitCredit = balances.get(node.id);
if (debitCredit == null) {
continue;
}
final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE;
node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]);
}
final List<Node> roots = new ArrayList<>();
for (final Node node : nodesById.values()) {
if (node.parentId == null) {
roots.add(node);
} else {
final Node parent = nodesById.get(node.parentId);
if (parent != null) {
parent.children.add(node);
} else {
// Parent was disabled (filtered out above) - surface this node at the top level
// rather than silently losing its balance from the report.
roots.add(node);
}
}
}
final Map<String, Object> debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE);
final Map<String, Object> creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOnDate.toString());
response.put("officeId", officeId);
response.put("debitSide", debitSide);
response.put("creditSide", creditSide);
return this.gson.toJson(response);
}
private Map<String, Object> buildSide(final String label, final List<Node> roots, final int... classifications) {
final List<Map<String, Object>> groups = new ArrayList<>();
BigDecimal sideTotal = BigDecimal.ZERO;
for (final int classification : classifications) {
final List<Map<String, Object>> nodeJsons = new ArrayList<>();
BigDecimal groupTotal = BigDecimal.ZERO;
for (final Node root : roots) {
if (root.classification != classification) {
continue;
}
nodeJsons.add(root.toJson());
groupTotal = groupTotal.add(root.balance());
}
if (!nodeJsons.isEmpty()) {
final Map<String, Object> group = new LinkedHashMap<>();
group.put("classification", classificationLabel(classification));
group.put("total", groupTotal);
group.put("nodes", nodeJsons);
groups.add(group);
sideTotal = sideTotal.add(groupTotal);
}
}
final Map<String, Object> side = new LinkedHashMap<>();
side.put("label", label);
side.put("total", sideTotal);
side.put("groups", groups);
return side;
}
private String classificationLabel(final int classification) {
return switch (classification) {
case ASSET -> "ASSET";
case LIABILITY -> "LIABILITY";
case EQUITY -> "EQUITY";
case INCOME -> "INCOME";
case EXPENSE -> "EXPENSE";
default -> "OTHER";
};
}
}
@@ -0,0 +1,58 @@
/**
* 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.accounting.glaccount.validation;
import org.apache.fineract.accounting.glaccount.domain.GLAccountUsage;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* A Header GL account (account_usage = HEADER) is a pure grouping node in the chart-of-accounts tree - it
* exists to organize Detail accounts underneath it, never to receive a transaction itself. The standard
* Fineract journal-entry command path (JournalEntryWritePlatformServiceJpaRepositoryImpl) never checked
* this - only "disabled" and "manual entries allowed" - and several features in this codebase post journal
* entries via direct JDBC INSERT into acc_gl_journal_entry, bypassing that command path entirely. This is
* the one shared check every such call site uses so a Header account can never end up with its own
* transaction (which would silently break every report that assumes a Header's balance is just the sum of
* its Detail children).
*/
public final class GLAccountTransactionGuard {
private GLAccountTransactionGuard() {}
public static void assertPostable(final JdbcTemplate jdbcTemplate, final Long glAccountId) {
if (glAccountId == null) {
return;
}
final Integer usage;
try {
usage = jdbcTemplate.queryForObject("SELECT account_usage FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.glaccount.not.found",
"GL account with id " + glAccountId + " does not exist.", "glAccountId", glAccountId);
}
if (GLAccountUsage.HEADER.getValue().equals(usage)) {
throw new PlatformDataIntegrityException("error.msg.glaccount.header.account.not.postable",
"GL account " + glAccountId + " is a Header account (a chart-of-accounts grouping node) and cannot receive a "
+ "transaction directly. Select a Detail account instead.",
"glAccountId", glAccountId);
}
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.accounting.journalentry.service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -391,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) { } else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null, throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode()); creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
} }
} }
@@ -775,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue()); final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
validateBusinessRulesForJournalEntries(journalEntryCommand); validateBusinessRulesForJournalEntries(journalEntryCommand);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/** /**
* revert old journal entries * revert old journal entries
*
* A transaction that carries a leg against the denomination-managed cash account (see
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
* entry against this same Opening Balances Contra account), so the query above finds it
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
* leg, would silently erase the cash account's opening balance every time an admin
* redefines opening balances for any OTHER account at this office.
*/ */
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId, final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
officeId); officeId);
for (String transactionId : transactionIdsToBeReversed) { for (String transactionId : transactionIdsToBeReversed) {
final List<JournalEntry> journalEntries = this.glJournalEntryRepository final List<JournalEntry> journalEntries = this.glJournalEntryRepository
.findUnReversedManualJournalEntriesByTransactionId(transactionId); .findUnReversedManualJournalEntriesByTransactionId(transactionId);
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
if (touchesDenominationManagedCashAccount) {
continue;
}
revertJournalEntry(journalEntries, "defining opening balance"); revertJournalEntry(journalEntries, "defining opening balance");
} }
@@ -865,6 +884,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} }
} }
/**
* The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared
* by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly
* which account(s) Migrate Cash Denomination owns.
*/
private List<Long> resolveDenominationManagedCashGlAccountIds() {
final List<Long> ids = new ArrayList<>();
for (final FinancialActivityAccount mapping : this.financialActivityAccountRepositoryWrapper.findAll()) {
final Integer type = mapping.getFinancialActivityType();
if ((type != null && (type == 101 || type == 102)) && mapping.getGlAccount() != null) {
ids.add(mapping.getGlAccount().getId());
}
}
return ids;
}
/**
* The GL account(s) mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) are
* managed exclusively through Migrate Cash Denomination (BranchDayRegisterApiResource#migrateCashDenomination),
* which keeps the GL figure tied to an actual counted note-and-coin breakdown, and validates any
* redefinition against that same count. Defining or redefining that account's opening balance here
* instead - as a bare number, with no denomination breakdown behind it - would let the GL figure
* drift away from what is physically in the till, with nothing left to catch the mismatch. So this
* command refuses to touch either of those accounts at all, on either side of the entry, rather than
* only when the amount would differ from what is already posted: the physical count is the only
* source of truth for this account, never a number typed into a generic opening-balance grid.
*/
private void validateNoDenominationManagedCashAccounts(final JournalEntryCommand command) {
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
if (denominationManagedGlAccountIds.isEmpty()) {
return;
}
final List<SingleDebitOrCreditEntryCommand> allEntries = new ArrayList<>();
if (command.getDebits() != null) {
allEntries.addAll(Arrays.asList(command.getDebits()));
}
if (command.getCredits() != null) {
allEntries.addAll(Arrays.asList(command.getCredits()));
}
for (final SingleDebitOrCreditEntryCommand entry : allEntries) {
if (denominationManagedGlAccountIds.contains(entry.getGlAccountId())) {
throw new GeneralPlatformDomainRuleException("error.msg.journalentry.opening.balance.cash.account.locked",
"This account is the tenant's Cash at Vault / Cash at Teller account and is managed through "
+ "Migrate Cash Denomination - it cannot be set or changed from Migrate Opening Balances.",
entry.getGlAccountId());
}
}
}
@Override @Override
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) { public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData); final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
@@ -11,6 +11,7 @@ import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.EmptyResultDataAccessException;
@@ -376,6 +377,7 @@ public class IncomeProfitTransferService {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) { final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, " + " entry_date, type_enum, amount, description, "
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter; import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter; import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter; import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
@@ -426,7 +427,10 @@ public class SecurityConfig {
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class) .addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class) .addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class) .addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class); .addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
// Refuses the handful of accounting features switched off for this installation.
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
if (loanCOBFilterHelper != null) { if (loanCOBFilterHelper != null) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(), http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
@@ -463,6 +467,10 @@ public class SecurityConfig {
return new RequestResponseFilter(); return new RequestResponseFilter();
} }
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
return new DisabledFeatureApiFilter();
}
public LoanCOBApiFilter loanCOBApiFilter() { public LoanCOBApiFilter loanCOBApiFilter() {
return new LoanCOBApiFilter(loanCOBFilterHelper); return new LoanCOBApiFilter(loanCOBFilterHelper);
} }
@@ -0,0 +1,128 @@
/**
* 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.core.filters;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Turns off a handful of accounting features at the HTTP edge, without deleting any of the code
* behind them: the resources, services and command handlers all stay exactly as they are, and only
* an inbound API call is refused. Re-enabling a feature is deleting its rule from RULES below.
*
* Blocking here rather than inside the resources matters for one specific reason: several internal
* flows raise journal entries through CommandWrapperBuilder().createJournalEntry() in Java
* (ClerkDisbursementRequestApiResource's GL offsets, clerk closure rounding, bank transfers). Those
* never travel over HTTP, so they keep working, while the "Create Journal Entries" screen and any
* direct call to the endpoint are refused.
*
* A rule carries an optional set of allowed "command" query parameter values, because
* POST /v1/journalentries is three different features behind one path: no command is the manual
* journal entry screen (blocked), command=defineOpeningBalance is Migrate Opening Balances and
* command=updateRunningBalance is the running-balance maintenance job (both still needed).
*/
public class DisabledFeatureApiFilter extends OncePerRequestFilter {
private record Rule(String feature, Set<String> methods, Pattern path, Set<String> allowedCommands) {
boolean matches(final String method, final String pathInfo, final String command) {
if (!methods.isEmpty() && !methods.contains(method)) {
return false;
}
if (!path.matcher(pathInfo).matches()) {
return false;
}
// A command this rule explicitly spares (a different feature sharing the same path).
return !(command != null && allowedCommands.contains(command));
}
}
private static final List<Rule> RULES = List.of(
// Create Journal Entries, and Frequent Postings which posts through the same endpoint.
new Rule("Create Journal Entries", Set.of("POST"), Pattern.compile("/v[1-9][0-9]*/journalentries/?"),
Set.of("defineOpeningBalance", "updateRunningBalance")),
// Closing Entries.
new Rule("Closing Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/glclosures(/.*)?"), Set.of()),
// Accruals.
new Rule("Accruals", Set.of(), Pattern.compile("/v[1-9][0-9]*/runaccruals(/.*)?"), Set.of()),
// Provisioning Entries, including the journal entries screen that belongs to it.
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/provisioningentries(/.*)?"), Set.of()),
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/journalentries/provisioning/?"), Set.of()),
// Accounting Rules. Only the Accounting Rules screens and Frequent Postings (disabled
// above) read these, so nothing still reachable depends on them.
new Rule("Accounting Rules", Set.of(), Pattern.compile("/v[1-9][0-9]*/accountingrules(/.*)?"), Set.of()));
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
final String pathInfo = request.getPathInfo();
if (StringUtils.isBlank(pathInfo)) {
filterChain.doFilter(request, response);
return;
}
final String method = request.getMethod();
// Read "command" out of the query string rather than through getParameter(): the latter
// triggers servlet parameter parsing, which for a form-encoded or multipart POST consumes
// the request body before the resource ever sees it. This filter sits in front of every
// API call, so it must not touch the body of requests it has no interest in.
final String command = commandFromQueryString(request.getQueryString());
for (final Rule rule : RULES) {
if (rule.matches(method, pathInfo, command)) {
reject(response, rule.feature());
return;
}
}
filterChain.doFilter(request, response);
}
private String commandFromQueryString(final String queryString) {
if (StringUtils.isBlank(queryString)) {
return null;
}
for (final String pair : queryString.split("&")) {
if (pair.startsWith("command=")) {
return URLDecoder.decode(pair.substring("command=".length()), StandardCharsets.UTF_8);
}
}
return null;
}
private void reject(final HttpServletResponse response, final String feature) throws IOException {
response.setStatus(HttpStatus.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter()
.write("{\"developerMessage\":\"" + feature + " is disabled on this installation.\","
+ "\"httpStatusCode\":\"403\",\"defaultUserMessage\":\"" + feature + " is not available.\","
+ "\"userMessageGlobalisationCode\":\"error.msg.feature.disabled\"}");
}
}
@@ -37,11 +37,13 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
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;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -71,13 +73,6 @@ public class BranchDayRegisterApiResource {
private final VaultPositionService vaultPositionService; private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
// never corrected -- without this same filter here, the close-preview's counted-in/counted-out
// figures would include that phantom amount, on top of the real cash the branch actually counted.
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
// ── GET /v1/branchday ── the register itself ────────────────────────────── // ── GET /v1/branchday ── the register itself ──────────────────────────────
/** /**
@@ -196,28 +191,32 @@ public class BranchDayRegisterApiResource {
// cross-check was being compared against every branch's counted cash at once, which makes it // cross-check was being compared against every branch's counted cash at once, which makes it
// wrong at every office but the first. A row whose officer maps to no cashier is still // wrong at every office but the first. A row whose officer maps to no cashier is still
// counted, for the same reason as the withdrawal legs in bookBreakdown. // counted, for the same reason as the withdrawal legs in bookBreakdown.
//
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
// timestamp is not.
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND (std.txn_id IS NULL OR std.txn_id < ?) AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id) WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?)) WHERE au.id = std.officer_id AND t.office_id = ?))
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); """, BigDecimal.class, Date.valueOf(businessDate), officeId));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND (std.txn_id IS NULL OR std.txn_id < ?) AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id) WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?)) WHERE au.id = std.officer_id AND t.office_id = ?))
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); """, BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn); result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut); result.put("denominationOut", countedOut);
@@ -247,11 +246,54 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations"); final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
requireCashMigrationBeforeFirstEverOpen(officeId);
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations, final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
openingDifferenceReason); openingDifferenceReason);
return gson.toJson(res); return gson.toJson(res);
} }
/**
* A branch's very first business day open is also the moment its Cash at Vault GL account is
* supposed to start agreeing with what is physically in the till - that is exactly what Migrate
* Cash Denomination exists to do (count the drawer, define the GL from that count, open the day).
* This plain endpoint has no denomination-reconciliation step of its own: it just records whatever
* lump sum or count it is given as the day's opening cash, with no requirement that it line up
* with the ledger. Used for a branch's first-ever open, that would let day-to-day cash tracking
* begin while the GL silently sits at whatever it already was (typically zero on a brand-new
* tenant) - a mismatch nothing downstream would ever catch, since nothing here compares the two.
*
* Only the very first open is blocked: once Migrate Cash Denomination has posted anything to the
* cash GL for this office (regardless of the amount - even a correctly counted zero still means
* it went through the reconciliation step), every subsequent day-to-day open/close on this screen
* is untouched, exactly as it already works.
*/
private void requireCashMigrationBeforeFirstEverOpen(final Long officeId) {
final Integer priorDays = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ?",
Integer.class, officeId);
if (priorDays != null && priorDays > 0) {
return;
}
final Map<String, Object> gl;
try {
gl = resolveCashGlAccount();
} catch (final PlatformDataIntegrityException e) {
// No Cash at Vault / Cash at Teller account configured for this tenant at all - nothing
// for this guard to reconcile against, so the plain open proceeds exactly as it always has.
return;
}
final Long glAccountId = ((Number) gl.get("id")).longValue();
final Integer postedEntries = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND office_id = ? AND reversed = false",
Integer.class, glAccountId, officeId);
if (postedEntries == null || postedEntries == 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.first.open.requires.cash.migration",
"This branch has never opened a business day before, and its " + gl.get("name")
+ " GL account has no opening balance yet. Use Migrate Cash Denomination to count the "
+ "vault and open the first business day together, instead of Open Day here.");
}
}
/** /**
* The actual open-day work, factored out of {@link #openDay(String)} so * The actual open-day work, factored out of {@link #openDay(String)} so
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it * {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
@@ -482,6 +524,7 @@ public class BranchDayRegisterApiResource {
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
@@ -87,9 +87,9 @@ public class CashierDepositRequestApiResource {
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination // DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no // breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
// real transaction id will reach in this system's lifetime makes every such match unambiguous, // real transaction id will reach in this system's lifetime makes every such match unambiguous,
// without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so // without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
// a request still sitting PENDING continues to count in cashier-position reports -- the cash is // band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
// physically in the drawer already, approval or not. // -- the cash is physically in the drawer already, approval or not.
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L; private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -47,13 +48,6 @@ public class CashReconciliationApiResource {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
// never corrected -- without this same filter here, this endpoint (unlike the cashier-position
// report) would count that phantom figure into a cashier's expected cash.
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
/** /**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd * GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier. * Returns denomination-wise expected balance for a cashier.
@@ -96,7 +90,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' " + "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " 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 = ?) "
@@ -126,7 +120,7 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' " + "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) " + "AND std.txn_date <= CAST(? AS DATE) "
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND std.officer_id IN (" + "AND std.officer_id IN ("
+ " 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 = ?) "
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
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.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -59,18 +60,14 @@ public class CashReportApiResource {
* </ul> * </ul>
* *
* <p> * <p>
* The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by * Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those * with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* rows are genuine cash that has to keep counting. Request ids and transaction ids are both * reads the same on all four.
* 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 + ") " + "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the // 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 // 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 // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
@@ -83,8 +80,9 @@ public class CashReportApiResource {
// everything counted after it) out of the cashier's position. // everything counted after it) out of the cashier's position.
// //
// Compared per transaction inside a correlated subquery rather than as a join, so a // 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 -- // breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// is untouched by this test and still handled by the id-magnitude test above. // manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS (" + "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt " + " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id" + " WHERE mt.id = std.txn_id"
@@ -1341,21 +1339,42 @@ public class CashReportApiResource {
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr"); final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr"); final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
// 2. Paged data query // 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
final String dataSql = "SELECT " // entry ever has a denomination breakdown for -- so on its own it names only the cash side
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
// entry actually moved money between. Every voucher raised through VoucherService (both this
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
// the same table the My Work register reads its own debit/credit columns from -- joined here
// by voucher_no so this report shows the identical pair rather than only the half this
// table's own denomination row happens to know about.
final String baseSql = "SELECT "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), " + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, " + " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, " + " MAX(std.txn_date) AS txn_date, "
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, " + " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
+ " MAX(std.created_at) AS sort_key, "
+ " MAX(std.officer_username) AS teller_name, " + " MAX(std.officer_username) AS teller_name, "
+ " MAX(std.payment_type) AS payment_type, " + " MAX(std.payment_type) AS payment_type, "
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, " + " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, " + " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount " + " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
+ baseGroupSql + baseGroupSql;
+ "ORDER BY MAX(std.created_at) "
final String dataSql = "SELECT b.*, "
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
+ "FROM (" + baseSql + ") b "
+ "LEFT JOIN LATERAL ("
+ " SELECT party_name, account_no FROM m_voucher_line"
+ " WHERE voucher_no = b.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 = b.voucher_no AND entry_type = 'CR'"
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
+ "ORDER BY b.sort_key "
+ "LIMIT ? OFFSET ?"; + "LIMIT ? OFFSET ?";
// Append limit and offset to params // Append limit and offset to params
@@ -1376,6 +1395,12 @@ public class CashReportApiResource {
row.put("txnSource", rs.getString("txn_source")); row.put("txnSource", rs.getString("txn_source"));
row.put("dr", rs.getBigDecimal("dr_amount")); row.put("dr", rs.getBigDecimal("dr_amount"));
row.put("cr", rs.getBigDecimal("cr_amount")); row.put("cr", rs.getBigDecimal("cr_amount"));
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
// was raised before m_voucher_line existed, or belongs to no register that writes one.
row.put("debitParty", rs.getString("debit_party"));
row.put("debitAccount", rs.getString("debit_account"));
row.put("creditParty", rs.getString("credit_party"));
row.put("creditAccount", rs.getString("credit_account"));
return row; return row;
}, pagedParams); }, pagedParams);
@@ -1,6 +1,8 @@
package org.apache.fineract.portfolio.cashtransfer.api; package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
@@ -21,6 +23,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 org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -239,7 +242,15 @@ public class ShareAdvanceCollectionApiResource {
// account to look in is never guessed from product configuration — the counter selects it // account to look in is never guessed from product configuration — the counter selects it
// by hand every time, since a product mapping can silently go missing or point at the // by hand every time, since a product mapping can silently go missing or point at the
// wrong account and a plain voucher lookup should never depend on that being right. // wrong account and a plain voucher lookup should never depend on that being right.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
if (adopted == null) {
// A member can also fund the same GL account through the Clerk's Disbursement Request
// register (a COLLECTION request debits the member's own savings account and credits a
// GL account, approved the same way a withdrawal is) — a second, equally legitimate way
// for money to reach Share Sundry / a fee-income account without a cashier counter
// deposit ever being raised.
adopted = adoptApprovedClerkDisbursementCollection(voucherNo, glAccountNoOverride);
}
if (adopted != null) { if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build(); return Response.ok(gson.toJson(adopted)).build();
} }
@@ -637,6 +648,81 @@ public class ShareAdvanceCollectionApiResource {
receiptNo); receiptNo);
} }
/**
* Adopts an approved Clerk Disbursement Request ("COLLECTION" direction) into the Share Sundry GL as a share
* advance collection.
*
* A COLLECTION request debits the member's own savings account and credits a GL account, approved by a manager
* the same way a clerk withdrawal is -- a second, equally legitimate way for a member's money to reach Share
* Sundry (or a fee-income account) besides a cashier counter deposit. The request itself carries no client id
* column; the member is only recorded, as JSON, on the debit leg of {@code credit_entries}.
*
* @param glAccountNoOverride
* required -- same contract as {@link #adoptApprovedDeposit}: the GL account the counter selected,
* never guessed from product configuration.
* @return the newly created collection row, or null when no approved COLLECTION request matches.
*/
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
final String glAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request"
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
+ " ORDER BY id DESC",
voucherNo, glAccountNo);
if (requests.isEmpty()) {
return null;
}
final Map<String, Object> req = requests.get(0);
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " does not debit a member's savings account, so it cannot be matched to a member.");
}
final Long clientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(clientId);
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at");
final Object submittedAt = req.get("submitted_at");
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
/** The savings account id named on the debit ("Dr") leg of a Clerk Disbursement Request's {@code credit_entries}
* JSON array, or null when that leg names a GL account instead (no member to match against). */
private Long debitSavingsAccountId(final String creditEntriesJson) {
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
return null;
}
final JsonArray entries = JsonParser.parseString(creditEntriesJson).getAsJsonArray();
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final boolean isDebit = entry.has("dbCr") && "Dr".equalsIgnoreCase(entry.get("dbCr").getAsString());
if (isDebit && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
return entry.get("savingsAccountId").getAsLong();
}
}
return null;
}
private Long loadClientOfficeId(final Long clientId) { private Long loadClientOfficeId(final Long clientId) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId); return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
} }
@@ -751,6 +837,7 @@ public class ShareAdvanceCollectionApiResource {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, " + " entry_date, type_enum, amount, description, entity_id, "
@@ -21,6 +21,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 org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
@@ -360,6 +361,7 @@ public class ShareCollectionApiResource {
final LocalDate entryDate, final Long shareAccountId, final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " "INSERT INTO acc_gl_journal_entry "
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
@@ -49,6 +49,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -919,6 +920,7 @@ public class ClerkClosureRequestApiResource {
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
@@ -49,6 +49,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -1061,6 +1062,7 @@ public class ClerkShareWithdrawalRequestApiResource {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, " + " entry_date, type_enum, amount, description, entity_id, "
@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.denominationmaster.service;
/**
* The one definition of which {@code m_savings_txn_denomination} rows are real counted cash.
*
* <p>
* Written here once because it is read from four places -- the branch day register's close preview,
* the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and
* those places disagreeing is what makes a drawer hold one figure on one screen and another on the
* next. A change to the rule belongs in this file and nowhere else.
*
* <h3>What the rule has to separate</h3>
*
* <p>
* The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes
* a different kind of id into it:
*
* <ul>
* <li>a savings, current or deposit transaction writes its own transaction id -- always small;
* <li>a cashier deposit request writes its request id plus a fixed offset -- also small;
* <li>a Demand Draft issue writes its own request id -- small;
* <li>a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into
* this column (see {@code CashierGlDenominationApiResource}, matching the encoding
* {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very
* large, but a hex string beginning with low digits can land anywhere;
* <li>and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to
* no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are
* carried forward for ever, and one database held ₹7.29 lakh of them.
* </ul>
*
* <h3>Why this is not a magnitude test</h3>
*
* <p>
* A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject
* anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows.
* A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661,
* which sits squarely inside the 13-digit band a millisecond epoch occupies.
*
* <p>
* What actually separates them is that a millisecond epoch stamped at write time decodes back to
* approximately the day the row itself belongs to, while a hex reference that happens to land in the
* same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026
* transaction date. So the test reads the id as a date and asks whether it lands on the row's own
* day. That is self-maintaining: it needs no constant moved forward as real timestamps grow.
*/
public final class CountedCashLedger {
private CountedCashLedger() {}
/** Widest gap still read as "this id is the moment the row was written", in days. */
private static final int SAME_DAY_TOLERANCE_DAYS = 7;
/**
* SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over
* the alias {@code std} ({@code m_savings_txn_denomination}).
*
* <p>
* The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside
* PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value
* this column can hold can overflow the comparison.
*
* <p>
* Padded with a space at each end so it reads the same spliced into a text block -- which strips
* the trailing space off the {@code AND } it follows -- as concatenated onto a plain string.
*/
public static final String REAL_TXN_ID = " (std.txn_id IS NULL"
+ " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000"
+ " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL"
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
}
@@ -40,14 +40,6 @@ public class DenominationBreakdownWriter {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
// never corrected -- without this same filter here, a cashier's computed stock (and the
// insufficient-stock check that guards a withdrawal against it) would count that phantom figure
// as real notes and coins in the drawer.
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
// ── Public API ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
/** /**
@@ -294,7 +286,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (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 = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -318,7 +310,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (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 = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -415,7 +407,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'" + " WHERE std.txn_type = 'DEPOSIT'"
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (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 = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -439,7 +431,7 @@ public class DenominationBreakdownWriter {
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std" + " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'" + " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND " + CountedCashLedger.REAL_TXN_ID
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " AND std.officer_id IN (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 = ?)"
+ " GROUP BY std.denomination_id" + " GROUP BY std.denomination_id"
@@ -46,6 +46,7 @@ import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -595,6 +596,8 @@ public class DividendClaimRequestApiResource {
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description, final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
final AppUser currentUser) { final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final String insertSql = """ final String insertSql = """
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
@@ -26,6 +26,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -378,6 +379,7 @@ public class SavingsPooledInterestPostingService {
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) { final Long transactionId) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
@@ -52,12 +52,21 @@ public final class DormancyClock {
* Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a * Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a
* WHERE clause without having to add a join it may already have — and so a client with no KYC * WHERE clause without having to add a join it may already have — and so a client with no KYC
* record at all falls back to the activation date rather than dropping out of the result. * record at all falls back to the activation date rather than dropping out of the result.
*
* <p>
* Null before the account is ever activated: {@code GREATEST} in Postgres ignores a null
* argument rather than propagating it, so without this guard an account still awaiting approval
* (activation date not yet set) would report the client's own KYC re-verification date as its
* "last live" date — a date that has nothing to do with this account, which has never been live
* at all.
*/ */
public static final String LAST_LIVE_ON = """ public static final String LAST_LIVE_ON = """
GREATEST( CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
sa.activatedon_date, GREATEST(
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k sa.activatedon_date,
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
sa.activatedon_date)) WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date))
END
"""; """;
} }
@@ -33,6 +33,7 @@ import jakarta.persistence.Transient;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -539,9 +540,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) { public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
ShareAccountTransaction returnTrans = null; ShareAccountTransaction returnTrans = null;
for (ShareAccountTransaction tran : this.shareAccountTransactions) { for (ShareAccountTransaction tran : this.shareAccountTransactions) {
// A charge/fee transaction carries no share count or unit price at all (both columns are
// null), so this account's own transaction list is not uniformly non-null the way a
// purchase-only list would be -- comparing those two fields has to tolerate that instead
// of assuming every transaction here is a share purchase.
if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate()) if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate())
&& tran.getTotalShares().equals(transaction.getTotalShares()) && Objects.equals(tran.getTotalShares(), transaction.getTotalShares())
&& tran.getPurchasePrice().compareTo(transaction.getPurchasePrice()) == 0 && nullSafeCompare(tran.getPurchasePrice(), transaction.getPurchasePrice())
&& tran.getTransactionStatus().equals(transaction.getTransactionStatus()) && tran.getTransactionStatus().equals(transaction.getTransactionStatus())
&& tran.getTransactionType().equals(transaction.getTransactionType())) { && tran.getTransactionType().equals(transaction.getTransactionType())) {
returnTrans = tran; returnTrans = tran;
@@ -551,6 +556,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
return returnTrans; return returnTrans;
} }
private boolean nullSafeCompare(final java.math.BigDecimal a, final java.math.BigDecimal b) {
if (a == null || b == null) {
return a == b;
}
return a.compareTo(b) == 0;
}
public LocalDate getSubmittedDate() { public LocalDate getSubmittedDate() {
return this.submittedDate; return this.submittedDate;
} }
@@ -27,6 +27,7 @@ import java.time.ZoneOffset;
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.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -442,6 +443,7 @@ public class AdditionalShareTrancheFundingService {
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) { final String description) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
this.jdbcTemplate.update( this.jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, " + " entry_date, type_enum, amount, description, entity_id, "
@@ -23,6 +23,7 @@ import java.time.LocalDate;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
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.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -145,6 +146,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date) submitted_on_date, transaction_date)
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
"""; """;
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, payableDividendsAccountId);
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, bankProfitAccountId);
// Debit first, then credit, so the entry ids follow the direction the money travelled. // Debit first, then credit, so the entry ids follow the direction the money travelled.
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
@@ -30,6 +30,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSource;
@@ -611,6 +612,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) { final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final AppUser currentUser = this.context.authenticatedUser(); final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
@@ -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:Keshav@898966} fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
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}
@@ -415,7 +415,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:Keshav@898966} spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
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}
@@ -69,6 +69,19 @@
<include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/> <include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/>
<include file="parts/0541_create_tenant_menu_access.xml" relativeToChangelogFile="true"/> <include file="parts/0541_create_tenant_menu_access.xml" relativeToChangelogFile="true"/>
<include file="parts/0542_split_loan_origination_investment_permissions.xml" relativeToChangelogFile="true"/> <include file="parts/0542_split_loan_origination_investment_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0543_remove_unused_roles_and_merge_manager_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0544_catchup_teller_is_deleted.xml" relativeToChangelogFile="true"/>
<include file="parts/0545_catchup_current_account_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0546_rename_cashier_role_to_title_case.xml" relativeToChangelogFile="true"/>
<include file="parts/0547_revoke_all_functions_from_clerk.xml" relativeToChangelogFile="true"/>
<include file="parts/0548_drop_unused_facility_charge_income_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0549_group_orphan_gl_accounts_under_headers.xml" relativeToChangelogFile="true"/>
<include file="parts/0550_uppercase_gl_account_names.xml" relativeToChangelogFile="true"/>
<!-- Numeric prefixes 0546-0548 above collide with the ones below — both sets of migrations
were authored in parallel on separate branches. Liquibase identifies a changeset by
id+author+filename, not by the numeric prefix, so the overlap is cosmetic only; renaming
either set would change that identity and make an already-applied changeset look new to
any tenant that ran it under its current filename, so the numbers are left as they are. -->
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/> <include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/> <include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/> <include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/>
@@ -60,11 +60,18 @@
<!-- Root detail accounts that stand outside any header group (cash tills) --> <!-- Root detail accounts that stand outside any header group (cash tills) -->
<changeSet id="0502-2" author="cbs-nitesh"> <changeSet id="0502-2" author="cbs-nitesh">
<!-- Checksum from the tenants that ran this before "Cash at Tellers" was dropped below. -->
<validCheckSum>9:c6e3096bd3dd10d5ee586a699e85d74f</validCheckSum>
<!--
"Cash at Tellers" (gl_code 2) is no longer seeded here: this tenant only ever operates one
cash GL account, "Cash at Vault", and every cash-resolving code path already prefers it over
a teller account when both exist (see BranchDayRegisterApiResource.resolveCashGlAccount()).
A brand-new tenant running this changeset from scratch should never get a second, parallel
cash account it has no reason to use.
-->
<sql> <sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy, description) INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy, description)
SELECT 'Cash at Vault', '1', false, true, 1, 1, '.', 'Test' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '1'); SELECT 'Cash at Vault', '1', false, true, 1, 1, '.', 'Test' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '1');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Cash at Tellers', '2', false, true, 1, 1, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '2');
</sql> </sql>
</changeSet> </changeSet>
@@ -60,12 +60,13 @@
created. The latest day is kept: it is the one carrying the current cycle's running total. created. The latest day is kept: it is the one carrying the current cycle's running total.
--> -->
<changeSet author="fineract" id="2"> <changeSet author="fineract" id="2">
<!-- Checksum from the tenants that ran this before the note below was corrected. -->
<validCheckSum>9:e03c93d35b7af6e12cd6bff87b5b8020</validCheckSum>
<!-- <!--
Skipped until the table exists. 0517 creates it, but 0517 lives in Skipped until the table exists. 0517 creates it, and is included ahead of this file in
final-changelog-tenant.xml which db.changelog-master includes after this changelog and changelog-tenant.xml, so on a brand-new tenant the table is already there — and already
all the modules — so on a brand-new tenant every changeSet here runs before the table carries the shapes the changeSets below would otherwise add, which is why each of them
does, and the failure aborts startup for every tenant rather than just the new one. checks for its own object rather than only for the table.
Nothing is lost by skipping: a table created later is created with these shapes already.
--> -->
<preConditions onFail="MARK_RAN"> <preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/> <tableExists tableName="m_savings_daily_interest"/>
@@ -78,8 +79,19 @@
</changeSet> </changeSet>
<changeSet author="fineract" id="3"> <changeSet author="fineract" id="3">
<!-- Checksum from the tenants that ran this before the constraint check below was added. -->
<validCheckSum>9:74afdaa4d7306bfa41f0a6939f653875</validCheckSum>
<preConditions onFail="MARK_RAN"> <preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/> <and>
<tableExists tableName="m_savings_daily_interest"/>
<!--
0517 creates this same constraint, and it is included ahead of this file in
changelog-tenant.xml, so on a brand-new tenant the constraint is already there
by the time this runs and adding it again aborts startup. Guarded the way 0517
guards its own creation of it.
-->
<not><indexExists indexName="uq_savings_daily_interest_account"/></not>
</and>
</preConditions> </preConditions>
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id" <addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
constraintName="uq_savings_daily_interest_account"/> constraintName="uq_savings_daily_interest_account"/>
@@ -97,8 +109,14 @@
</changeSet> </changeSet>
<changeSet author="fineract" id="5"> <changeSet author="fineract" id="5">
<!-- Checksum from the tenants that ran this before the index check below was added. -->
<validCheckSum>9:9af730db6b99301bc2c87785afa42e78</validCheckSum>
<preConditions onFail="MARK_RAN"> <preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/> <and>
<tableExists tableName="m_savings_daily_interest"/>
<!-- Same story as changeSet 3: 0517 already creates this index on a new tenant. -->
<not><indexExists indexName="idx_sdi_product"/></not>
</and>
</preConditions> </preConditions>
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest"> <createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
<column name="savings_product_id"/> <column name="savings_product_id"/>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- 'Self Service User' was audited across every tenant and found with zero users assigned
anywhere and no wired-up purpose. Principal Officer is kept - it is the statutory
PMLA/RBI compliance seat added in 0435, just not yet assigned to anyone. -->
<changeSet id="0543-1" author="cbs-nitesh">
<sql>
DELETE FROM m_role_permission
WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User')
</sql>
<sql>
DELETE FROM m_appuser_role
WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User')
</sql>
<sql>
DELETE FROM m_role WHERE name = 'Self Service User'
</sql>
</changeSet>
<!-- 'As Manager' and 'Branch Manager' covered overlapping ground (loan/cash operations vs.
portfolio/savings operations). Merging them into one surviving role - 'Branch Manager' -
rather than just keeping both in sync: Branch Manager first picks up any permission only
As Manager had, then As Manager's users move onto Branch Manager, then As Manager itself
is removed. -->
<changeSet id="0543-2" author="cbs-nitesh">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT (SELECT id FROM m_role WHERE name = 'Branch Manager'), rp.permission_id
FROM m_role_permission rp
WHERE rp.role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission existing
WHERE existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager')
AND existing.permission_id = rp.permission_id)
</sql>
<sql>
INSERT INTO m_appuser_role (appuser_id, role_id)
SELECT ur.appuser_id, (SELECT id FROM m_role WHERE name = 'Branch Manager')
FROM m_appuser_role ur
WHERE ur.role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
AND NOT EXISTS (
SELECT 1 FROM m_appuser_role existing
WHERE existing.appuser_id = ur.appuser_id
AND existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager'))
</sql>
<sql>
DELETE FROM m_appuser_role WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
</sql>
<sql>
DELETE FROM m_role_permission WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
</sql>
<sql>
DELETE FROM m_role WHERE name = 'As Manager'
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,41 @@
<?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
-->
<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.1.xsd">
<!-- m_tellers.is_deleted was introduced by 0338_add_soft_delete_to_tellers, whose precondition is
an <and> of tableExists(m_tellers) + not columnExists(is_deleted) under onFail="MARK_RAN".
On at least one tenant that precondition evaluated false, so 0338 was stamped into
DATABASECHANGELOG without ever adding the column - and a stamped changeset can never
re-apply. Every teller read then fails with "column t.is_deleted does not exist", because
TellerManagementReadPlatformServiceImpl filters on it (findTeller / retrieveAllTellers) and
Teller maps it as a non-null field. This is a fresh changeset id rather than an edit to 0338
for that same reason: editing 0338 would only break checksum validation on the tenants where
it did run, and would still not re-execute anywhere.
The precondition here is deliberately just the columnExists check - no tableExists in the
<and> - so MARK_RAN can only be reached when the column genuinely already exists. -->
<changeSet id="0544_catchup_teller_is_deleted" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_tellers" columnName="is_deleted"/></not>
</preConditions>
<addColumn tableName="m_tellers">
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,53 @@
<?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
-->
<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.1.xsd">
<!-- m_current_account is created by 0276 and only ever gained two more columns: is_linking_enable
(0339) and terminate_true (0340_2). Both are stamped in DATABASECHANGELOG on tenants where
the column is nevertheless absent, so neither can re-apply - CurrentAccountReadPlatformService
selects ca.* and filters on ca.terminate_true, and every current-account list 500s with
"column ca.terminate_true does not exist".
This is the same drift the 03xx catch-up series already reconciled for the sibling tables -
0363/0364 for the product tables, 0368 for m_savings_account (which includes
m_savings_account.terminate_true, the other half of 0340) - m_current_account was simply
never given one. Each column is guarded by its own columnExists precondition so a tenant
that has one but not the other is fixed correctly, and column definitions match 0339/0340
exactly so tenants converge on one shape. -->
<changeSet id="0545_catchup_current_account_is_linking_enable" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_current_account" columnName="is_linking_enable"/></not>
</preConditions>
<addColumn tableName="m_current_account">
<column name="is_linking_enable" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0545_catchup_current_account_terminate_true" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_current_account" columnName="terminate_true"/></not>
</preConditions>
<addColumn tableName="m_current_account">
<column name="terminate_true" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- Every other role reads in title case ('Clerk', 'Teller', 'Branch Manager'); 'CASHIER' was
the one seeded in all caps, which is what showed up as an all-caps chip in the user-role
picker. Role name is only ever matched case-insensitively (CashierRoleHelper.isCashierOnly,
validateCheckerRole), so this rename changes nothing about who counts as a cashier. -->
<changeSet id="0546-1" author="cbs-nitesh">
<sql>
UPDATE m_role SET name = 'Cashier' WHERE name = 'CASHIER'
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- Clerk somehow carried ALL_FUNCTIONS, which bypasses every individual permission check
(HasPermissionDirective, and this table's own role-permission rows) unconditionally - a
Clerk should only ever have the specific request/submit rights this role's own explicit
permission rows already grant, never full access. Revoking just that one row; Clerk's
real, explicit permission set is untouched. -->
<changeSet id="0547-1" author="cbs-nitesh">
<sql>
DELETE FROM m_role_permission
WHERE role_id = (SELECT id FROM m_role WHERE name = 'Clerk')
AND permission_id = (SELECT id FROM m_permission WHERE code = 'ALL_FUNCTIONS')
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!--
Changelog 0504 seeded one income account per facility charge (400201 to 400216, under the
400200 "Facility & Service Charges Income" header), on the assumption that each facility
would post its fee to its own account.
That assumption was dropped: FacilityChargeSeederService deliberately leaves a facility
charge's income_or_liability_account_id NULL, because Fineract prefers a charge's own GL
account over the savings product's "Income From Fees" mapping, and setting one would make
every product post all sixteen facilities to the same shared account. Fee income therefore
routes through each product's own mapping instead, and these sixteen accounts have nothing
pointing at them: no charge, no product mapping, no financial-activity mapping and no
journal entry, on any tenant. They only clutter every GL dropdown and the balance sheet.
Dropped per account rather than in one statement, and only where the account is genuinely
unreferenced, so an installation that did wire one up (or posted to it) keeps it along with
the history an auditor would need. The header goes last, and only once nothing is left
under it.
-->
<changeSet id="0548-1" author="cbs-nitesh">
<sql>
DELETE FROM acc_gl_account a
WHERE a.gl_code IN ('400201','400202','400203','400204','400205','400206','400207','400208',
'400209','400210','400211','400212','400213','400214','400215','400216')
AND NOT EXISTS (SELECT 1 FROM acc_gl_journal_entry je WHERE je.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_product_mapping pm WHERE pm.gl_account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_gl_financial_activity_account fa WHERE fa.gl_account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM m_charge c WHERE c.income_or_liability_account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_gl_account child WHERE child.parent_id = a.id)
</sql>
</changeSet>
<changeSet id="0548-2" author="cbs-nitesh">
<sql>
DELETE FROM acc_gl_account a
WHERE a.gl_code = '400200'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account child WHERE child.parent_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_gl_journal_entry je WHERE je.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_product_mapping pm WHERE pm.gl_account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM acc_gl_financial_activity_account fa WHERE fa.gl_account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM m_charge c WHERE c.income_or_liability_account_id = a.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!--
A number of Detail accounts were seeded one feature at a time (cash drawers, dividend,
escheat, round off) and never placed under a group, so they sat at the top of the chart
alongside the real group headings. On a balance sheet that reads as a loose line with no
heading over it, which is not how a bank's sheet is laid out: every figure belongs to a
named group that subtotals.
Grouped here the way a co-operative bank's own balance sheet groups them, and the gl_codes
already imply these parents (100101/100102 under 100100, 2009xx under 200900, 3009xx under
300900). Codes are left alone. Only the parent link and the hierarchy path change, so
nothing that refers to an account by its code is affected.
Every statement is conditional on the account still being an unparented Detail, so a tenant
that already groups one of these keeps its own arrangement, and a tenant that never had the
account is simply skipped.
-->
<!-- Group headings, created only where something will actually sit under them. -->
<changeSet id="0549-1" author="cbs-nitesh">
<sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Cash on Hand', '100100', false, true, 1, 2, '.'
WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100100')
AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('100101','100102') AND parent_id IS NULL AND account_usage = 1)
</sql>
<sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Sundry and Statutory Liabilities', '200900', false, true, 2, 2, '.'
WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200900')
AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('200901','200902','200903','200904','200905','200906') AND parent_id IS NULL AND account_usage = 1)
</sql>
<sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Contra Accounts', '300800', false, true, 3, 2, '.'
WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300800')
AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300000' AND parent_id IS NULL AND account_usage = 1)
</sql>
<sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Reserves and Surplus', '300900', false, true, 3, 2, '.'
WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300900')
AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('300901','300902','300903') AND parent_id IS NULL AND account_usage = 1)
</sql>
<sql>
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy)
SELECT 'Other Income', '400900', false, true, 4, 2, '.'
WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400900')
AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400901' AND parent_id IS NULL AND account_usage = 1)
</sql>
</changeSet>
<!--
Place each account under its group. hierarchy mirrors what GLAccount.generateHierarchy()
writes for a child of a root heading: the parent's path, then this account's own id.
-->
<changeSet id="0549-2" author="cbs-nitesh">
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.gl_code = '100100'
AND a.gl_code IN ('100101','100102')
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.gl_code = '200900'
AND a.gl_code IN ('200901','200902','200903','200904','200905','200906')
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.gl_code = '300800'
AND a.gl_code = '300000'
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.gl_code = '300900'
AND a.gl_code IN ('300901','300902','300903')
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.gl_code = '400900'
AND a.gl_code = '400901'
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
</changeSet>
<!--
Two accounts belong under groups the chart already carries, so no new heading is made for
them. The parent is picked from whichever heading this tenant actually has: a tenant seeded
from the standard chart has 500400 and 400100, one seeded from the Basoda chart has 501000.
-->
<changeSet id="0549-3" author="cbs-nitesh">
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.id = (SELECT id FROM acc_gl_account
WHERE gl_code IN ('500400','501000','500100') AND account_usage = 2
ORDER BY CASE gl_code WHEN '500400' THEN 1 WHEN '501000' THEN 2 ELSE 3 END
LIMIT 1)
AND a.gl_code = '501200'
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
<sql>
UPDATE acc_gl_account a
SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.'
FROM acc_gl_account h
WHERE h.id = (SELECT id FROM acc_gl_account
WHERE gl_code IN ('400100','400900') AND account_usage = 2
ORDER BY CASE gl_code WHEN '400100' THEN 1 ELSE 2 END
LIMIT 1)
AND a.gl_code = '400103'
AND a.parent_id IS NULL AND a.account_usage = 1
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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">
<!--
This tenant's real chart of accounts (the bank's own printed balance sheet) names every
account in capitals: "SUNDRY CREDITORS A/C", "CASH ON HAND", etc. A run of accounts seeded
by earlier feature migrations (0502, 0503, 0549 among them) used mixed-case display names
instead ("Cash on Hand", "Share Sundry"...), which never matched what the bank's own
statements call these accounts.
"Share Sundry" (and, on tenants where 0503's rename ran before this file existed, its
plain "Sundry" form) is the same holding account the bank's own ledger calls
"SUNDRY CREDITORS A/C", renamed here to match. This only changes the display name column;
the account's gl_code, id, and every financial-activity/product mapping that resolves it
by id are untouched, so nothing that posts to it needs to change.
-->
<changeSet author="cbs-nitesh" id="0550-1-rename-share-sundry">
<sql>
UPDATE acc_gl_account SET name = 'SUNDRY CREDITORS A/C' WHERE name IN ('Share Sundry', 'Sundry');
</sql>
</changeSet>
<!-- Every other mixed-case account name, brought in line with the same all-caps convention. -->
<changeSet author="cbs-nitesh" id="0550-2-uppercase-remaining-names">
<sql>
UPDATE acc_gl_account SET name = UPPER(name) WHERE name &lt;&gt; UPPER(name);
</sql>
</changeSet>
</databaseChangeLog>