From 04ac233fbea1f5f4191cfbae7d4628e03ed13032 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 13 Sep 2026 15:22:07 +0530 Subject: [PATCH] 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 --- .../JournalEntryInvalidException.java | 5 + .../api/BalanceSheetApiResource.java | 243 ++++++++++++++++++ .../validation/GLAccountTransactionGuard.java | 58 +++++ ...WritePlatformServiceJpaRepositoryImpl.java | 68 +++++ .../service/IncomeProfitTransferService.java | 2 + .../core/config/SecurityConfig.java | 10 +- .../filters/DisabledFeatureApiFilter.java | 128 +++++++++ .../api/BranchDayRegisterApiResource.java | 45 ++++ .../ShareAdvanceCollectionApiResource.java | 89 ++++++- .../api/ShareCollectionApiResource.java | 2 + .../api/ClerkClosureRequestApiResource.java | 2 + ...lerkShareWithdrawalRequestApiResource.java | 2 + .../api/DividendClaimRequestApiResource.java | 3 + .../SavingsPooledInterestPostingService.java | 2 + .../savings/service/DormancyClock.java | 19 +- .../shareaccounts/domain/ShareAccount.java | 16 +- .../AdditionalShareTrancheFundingService.java | 2 + .../ShareAccountSchedularServiceImpl.java | 3 + ...WritePlatformServiceJpaRepositoryImpl.java | 3 + .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 3 + .../0502_backfill_core_chart_of_accounts.xml | 11 +- ...ngs_daily_interest_one_row_per_account.xml | 32 ++- .../parts/0544_catchup_teller_is_deleted.xml | 41 +++ .../0545_catchup_current_account_columns.xml | 53 ++++ ...unused_facility_charge_income_accounts.xml | 49 ++++ ...group_orphan_gl_accounts_under_headers.xml | 134 ++++++++++ .../parts/0550_uppercase_gl_account_names.xml | 50 ++++ 28 files changed, 1059 insertions(+), 20 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java index ac1a7ee77..6a810446f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java @@ -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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java new file mode 100644 index 000000000..82333046a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java @@ -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 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 toJson() { + final Map 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> 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 nodesById = new LinkedHashMap<>(); + for (final Map 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 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> 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 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 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 debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE); + final Map creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME); + + final Map 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 buildSide(final String label, final List roots, final int... classifications) { + final List> groups = new ArrayList<>(); + BigDecimal sideTotal = BigDecimal.ZERO; + for (final int classification : classifications) { + final List> 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 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 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"; + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java new file mode 100644 index 000000000..08b7cea5a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java @@ -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); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 2f79ebae8..195582cf4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -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 denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds(); final List transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId, officeId); for (String transactionId : transactionIdsToBeReversed) { final List 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 resolveDenominationManagedCashGlAccountIds() { + final List 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 denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds(); + if (denominationManagedGlAccountIds.isEmpty()) { + return; + } + final List 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 accountingBridgeData) { final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 464ddee50..18d5cf7db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -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, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index ca53139c8..b918422a1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java new file mode 100644 index 000000000..cc5d2bd54 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java @@ -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 methods, Pattern path, Set 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 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\"}"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 1006863bf..9a2281763 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -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> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); + requireCashMigrationBeforeFirstEverOpen(officeId); + final Map 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 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index e5c162e48..b45a7fe52 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -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 adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); + Map 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 adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) { + final String glAccountNo = glAccountNoOverride.trim(); + final List> 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 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, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java index bca22f22d..1ee09f3a3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java @@ -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, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 65056173c..a1f2a0cfe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 969fe1a44..7e2a365fe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -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, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index eb00771ee..1b5a4d8ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 724686933..d5e81a559 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java index e576911c0..7a5c235c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -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. + * + *

+ * 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 = """ - 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)) + 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 """; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index fa87d7cfc..3282fd5a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -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 { 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 { 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index d39cc5764..2986803f5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -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, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index b735ef91a..595e617df 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index bbfa1822a..215f57def 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -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); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..108e7421a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 02bfb8312..b3624aac4 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -74,4 +74,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml index c1c78f4cb..0fda9095b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml @@ -60,11 +60,18 @@ + + 9:c6e3096bd3dd10d5ee586a699e85d74f + 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'); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml index a28aee782..3632ec227 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -60,12 +60,13 @@ created. The latest day is kept: it is the one carrying the current cycle's running total. --> + + 9:e03c93d35b7af6e12cd6bff87b5b8020 @@ -78,8 +79,19 @@ + + 9:74afdaa4d7306bfa41f0a6939f653875 - + + + + + @@ -97,8 +109,14 @@ + + 9:9af730db6b99301bc2c87785afa42e78 - + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml new file mode 100644 index 000000000..f30f83d1a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml new file mode 100644 index 000000000..2635d72d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml new file mode 100644 index 000000000..38ac14bb7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml @@ -0,0 +1,49 @@ + + + + + + + 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) + + + + + + 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) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml new file mode 100644 index 000000000..b203a843e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml @@ -0,0 +1,134 @@ + + + + + + + + + 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) + + + 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) + + + 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) + + + 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) + + + 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) + + + + + + + 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 + + + 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 + + + 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 + + + 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 + + + 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 + + + + + + + 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 + + + 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 + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml new file mode 100644 index 000000000..f921a8b68 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml @@ -0,0 +1,50 @@ + + + + + + + + UPDATE acc_gl_account SET name = 'SUNDRY CREDITORS A/C' WHERE name IN ('Share Sundry', 'Sundry'); + + + + + + + UPDATE acc_gl_account SET name = UPPER(name) WHERE name <> UPPER(name); + + + +