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:
+5
@@ -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();
|
||||
}
|
||||
|
||||
+243
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
|
||||
+2
@@ -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, "
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
|
||||
+128
@@ -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\"}");
|
||||
}
|
||||
}
|
||||
+45
@@ -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,
|
||||
|
||||
+88
-1
@@ -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, "
|
||||
|
||||
+2
@@ -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, "
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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, "
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+9
@@ -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
|
||||
""";
|
||||
}
|
||||
|
||||
+14
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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, "
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+9
-2
@@ -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>
|
||||
|
||||
|
||||
+23
-5
@@ -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"/>
|
||||
|
||||
+41
@@ -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>
|
||||
+53
@@ -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>
|
||||
+49
@@ -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>
|
||||
+134
@@ -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>
|
||||
+50
@@ -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 <> UPPER(name);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user