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>
This commit is contained in:
2026-09-13 15:22:07 +05:30
co-authored by Claude Sonnet 5
parent 9e0c04583d
commit 04ac233fbe
28 changed files with 1059 additions and 20 deletions
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
GL_ACCOUNT_DISABLED, //
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
GL_ACCOUNT_IS_HEADER, //
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
public String errorMessage() {
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "Invalid debit or credit accounts are passed";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
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();
}
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_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();
}
@@ -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.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -391,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
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());
validateBusinessRulesForJournalEntries(journalEntryCommand);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/**
* 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,
officeId);
for (String transactionId : transactionIdsToBeReversed) {
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
.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");
}
@@ -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
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
@@ -10,6 +10,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
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.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -359,6 +360,7 @@ public class IncomeProfitTransferService {
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) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " 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.filters.CallerIpTrackingFilter;
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.IdempotencyStoreHelper;
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
@@ -426,7 +427,10 @@ public class SecurityConfig {
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.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) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
@@ -463,6 +467,10 @@ public class SecurityConfig {
return new RequestResponseFilter();
}
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
return new DisabledFeatureApiFilter();
}
public LoanCOBApiFilter loanCOBApiFilter() {
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,6 +37,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -247,11 +248,54 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
requireCashMigrationBeforeFirstEverOpen(officeId);
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
openingDifferenceReason);
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
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
@@ -482,6 +526,7 @@ public class BranchDayRegisterApiResource {
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 AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
@@ -1,6 +1,8 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
@@ -21,6 +23,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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
// 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.
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) {
return Response.ok(gson.toJson(adopted)).build();
}
@@ -637,6 +648,81 @@ public class ShareAdvanceCollectionApiResource {
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) {
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,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"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, "
@@ -21,6 +21,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -360,6 +361,7 @@ public class ShareCollectionApiResource {
final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_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.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(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.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"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, "
@@ -45,6 +45,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -587,6 +588,8 @@ public class DividendClaimRequestApiResource {
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
final AppUser currentUser) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final String insertSql = """
INSERT INTO acc_gl_journal_entry
(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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
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,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(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
* 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.
*
* <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 = """
CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
GREATEST(
sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
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.LocalDateTime;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -539,9 +540,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
ShareAccountTransaction returnTrans = null;
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())
&& tran.getTotalShares().equals(transaction.getTotalShares())
&& tran.getPurchasePrice().compareTo(transaction.getPurchasePrice()) == 0
&& Objects.equals(tran.getTotalShares(), transaction.getTotalShares())
&& nullSafeCompare(tran.getPurchasePrice(), transaction.getPurchasePrice())
&& tran.getTransactionStatus().equals(transaction.getTransactionStatus())
&& tran.getTransactionType().equals(transaction.getTransactionType())) {
returnTrans = tran;
@@ -551,6 +556,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
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() {
return this.submittedDate;
}
@@ -27,6 +27,7 @@ import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
this.jdbcTemplate.update(
"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, "
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -156,6 +157,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
submitted_on_date, transaction_date)
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.
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
@@ -29,6 +29,7 @@ import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
@@ -660,6 +661,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
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) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
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.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
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.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -74,4 +74,7 @@
<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"/>
</databaseChangeLog>
@@ -60,11 +60,18 @@
<!-- Root detail accounts that stand outside any header group (cash tills) -->
<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>
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');
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>
</changeSet>
@@ -60,12 +60,13 @@
created. The latest day is kept: it is the one carrying the current cycle's running total.
-->
<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
final-changelog-tenant.xml which db.changelog-master includes after this changelog and
all the modules — so on a brand-new tenant every changeSet here runs before the table
does, and the failure aborts startup for every tenant rather than just the new one.
Nothing is lost by skipping: a table created later is created with these shapes already.
Skipped until the table exists. 0517 creates it, and is included ahead of this file in
changelog-tenant.xml, so on a brand-new tenant the table is already there — and already
carries the shapes the changeSets below would otherwise add, which is why each of them
checks for its own object rather than only for the table.
-->
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
@@ -78,8 +79,19 @@
</changeSet>
<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">
<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>
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
constraintName="uq_savings_daily_interest_account"/>
@@ -97,8 +109,14 @@
</changeSet>
<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">
<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>
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
<column name="savings_product_id"/>
@@ -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,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>