Compare commits
6
Commits
c20a7d0728
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af43944699 | ||
|
|
39fa100c70 | ||
|
|
1c6ac9d77f | ||
|
|
6bb512e1fe | ||
|
|
04ac233fbe | ||
|
|
9e0c04583d |
+20
-9
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
|
||||
*/
|
||||
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
||||
|
||||
/**
|
||||
* Activities an administrator may map from the Financial Activity Mappings screen.
|
||||
*
|
||||
* Held in one place because create and update both validate against it, and the two lists had already drifted -
|
||||
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
|
||||
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
|
||||
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
|
||||
*/
|
||||
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
|
||||
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
|
||||
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
|
||||
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
|
||||
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
|
||||
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
|
||||
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
|
||||
|
||||
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
||||
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||
|
||||
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||
element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
+26
-1
@@ -451,7 +451,32 @@ public final class AccountingConstants {
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||
/**
|
||||
* The year's closed-out profit a dividend is declared against.
|
||||
*
|
||||
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||
* distribution a second time.
|
||||
*/
|
||||
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||
* calculation.
|
||||
*/
|
||||
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where the paise a dividend cannot divide evenly are collected.
|
||||
*
|
||||
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||
*
|
||||
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||
*/
|
||||
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+43
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||
* A rule named for withdrawals must not fire on a deposit.
|
||||
*/
|
||||
@Column(name = "direction", length = 10)
|
||||
private String direction;
|
||||
|
||||
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||
@Column(name = "cash_only", nullable = false)
|
||||
private boolean cashOnly;
|
||||
|
||||
/**
|
||||
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||
* many small transactions; without this floor a single large one satisfies the window total and
|
||||
* reads as a pattern it is not.
|
||||
*/
|
||||
@Column(name = "min_transaction_count")
|
||||
private Integer minTransactionCount;
|
||||
|
||||
/**
|
||||
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||
* about the transaction alone and so applies to everyone.
|
||||
*/
|
||||
@Column(name = "subject_scope", length = 30)
|
||||
private String subjectScope;
|
||||
|
||||
public String getSubjectScope() {
|
||||
return subjectScope;
|
||||
}
|
||||
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public boolean isCashOnly() {
|
||||
return cashOnly;
|
||||
}
|
||||
|
||||
public Integer getMinTransactionCount() {
|
||||
return minTransactionCount;
|
||||
}
|
||||
|
||||
protected AmlTransactionRule() {}
|
||||
|
||||
/**
|
||||
|
||||
+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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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.common;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
|
||||
* configures.
|
||||
*
|
||||
* <p>
|
||||
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
|
||||
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
|
||||
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
|
||||
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
|
||||
* screen.
|
||||
*
|
||||
* <p>
|
||||
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
|
||||
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
|
||||
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
|
||||
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DividendGlAccountResolver {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The year's closed-out profit a dividend is declared against. */
|
||||
public Long distributableProfitAccount() {
|
||||
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
|
||||
}
|
||||
|
||||
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
|
||||
public Long retainedProfitAccount() {
|
||||
return requireMapped(FinancialActivity.RETAINED_PROFIT);
|
||||
}
|
||||
|
||||
/** Equity the declared profit is set aside in, between declaration and board approval. */
|
||||
public Long reserveForDividendAccount() {
|
||||
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
|
||||
}
|
||||
|
||||
/** The liability to members, created on approval and discharged as each member claims. */
|
||||
public Long payableDividendsAccount() {
|
||||
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the paise a dividend cannot divide evenly are swept.
|
||||
*
|
||||
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
|
||||
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
|
||||
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
|
||||
* a decision the society has failed to make, which is why it resolves rather than throwing.
|
||||
*/
|
||||
public Long dividendRoundOffAccount() {
|
||||
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
|
||||
return roundOff != null ? roundOff : retainedProfitAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
|
||||
*
|
||||
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
|
||||
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
|
||||
* returns null instead of throwing.
|
||||
*/
|
||||
public Long dividendPaidAccountOrNull() {
|
||||
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
|
||||
}
|
||||
|
||||
/** The GL mapped to an activity, or null when the society has not configured it. */
|
||||
public Long mappedAccount(final FinancialActivity activity) {
|
||||
return this.jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
activity.getValue())
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private Long requireMapped(final FinancialActivity activity) {
|
||||
final Long glAccountId = mappedAccount(activity);
|
||||
if (glAccountId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
|
||||
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
|
||||
+ "Mappings before running the dividend cycle.",
|
||||
activity.getValue());
|
||||
}
|
||||
return glAccountId;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
|
||||
+7
-7
@@ -37,6 +37,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -60,13 +61,12 @@ import org.springframework.stereotype.Component;
|
||||
public class DistributableProfitApiResource {
|
||||
|
||||
/**
|
||||
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
|
||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
||||
* The year's closed-out profit, which is what a dividend may be declared against.
|
||||
*
|
||||
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* offer profit already distributed once for distribution again.
|
||||
*/
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||
|
||||
@@ -162,9 +162,9 @@ public class DistributableProfitApiResource {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
|
||||
java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource {
|
||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: incomeGlCodeParam.trim();
|
||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
|
||||
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
|
||||
|
||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||
: financialYearParam.trim();
|
||||
|
||||
+34
-15
@@ -10,6 +10,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
@@ -22,7 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
*
|
||||
* <pre>
|
||||
* Dr Share Entry Fee Income (10.4) = balance for the year
|
||||
* Cr Bank Profit (BANKPROFIT01)
|
||||
* Cr Distributable Profit (the GL mapped to that financial activity)
|
||||
* </pre>
|
||||
*
|
||||
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
||||
@@ -42,22 +44,23 @@ public class IncomeProfitTransferService {
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
/** Equity GL that accumulates profit. */
|
||||
/**
|
||||
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
|
||||
*
|
||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
|
||||
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
|
||||
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||
* decision — offering the same money for distribution twice.
|
||||
*/
|
||||
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
|
||||
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
||||
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* The year's profit is closed into the Distributable Profit account, not the Retained one.
|
||||
*
|
||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit
|
||||
* once the declaration is approved. Closing into Retained directly would mix the year just ended with earlier
|
||||
* years' leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||
* decision — offering the same money for distribution twice.
|
||||
*
|
||||
* Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01.
|
||||
*/
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
/**
|
||||
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
||||
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
||||
@@ -72,7 +75,7 @@ public class IncomeProfitTransferService {
|
||||
final String note, final boolean strict) {
|
||||
|
||||
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
|
||||
@@ -165,7 +168,7 @@ public class IncomeProfitTransferService {
|
||||
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
||||
final boolean strict) {
|
||||
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Long officeId = headOfficeId();
|
||||
@@ -283,7 +286,7 @@ public class IncomeProfitTransferService {
|
||||
*/
|
||||
public BigDecimal profitForFinancialYear(final String financialYear) {
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
||||
@@ -342,6 +345,21 @@ public class IncomeProfitTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
/** As {@link #loadGlByCode}, for an account already identified by a financial activity mapping. */
|
||||
public Map<String, Object> loadGlById(final Long glAccountId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found",
|
||||
"GL account " + glAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
/** The distributable-profit GL the year's income closes into, as configured on Financial Activity Mappings. */
|
||||
public Map<String, Object> loadDistributableProfitGl() {
|
||||
return loadGlById(this.dividendGlAccounts.distributableProfitAccount());
|
||||
}
|
||||
|
||||
public Long headOfficeId() {
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||
}
|
||||
@@ -359,6 +377,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\"}");
|
||||
}
|
||||
}
|
||||
+14
@@ -25,6 +25,7 @@ import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
@@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -47,6 +49,7 @@ public class AmlCtrReportApiResource {
|
||||
private final AmlCtrReportReadPlatformService readPlatformService;
|
||||
private final AmlCtrReportGenerationService generationService;
|
||||
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
||||
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -58,6 +61,17 @@ public class AmlCtrReportApiResource {
|
||||
return this.toApiJsonSerializer.serialize(reports);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("lines")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "The reportable lines behind the generated CTRs",
|
||||
description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.")
|
||||
public String retrieveLines(@QueryParam("reportId") final Long reportId) {
|
||||
this.context.authenticatedUser();
|
||||
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("generate")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather
|
||||
* than only the ids the line is stored under.
|
||||
*
|
||||
* <p>
|
||||
* The stored line keeps a client id and a transaction id; a filing needs the account, the customer,
|
||||
* the branch and the date those ids stand for. They are joined here so the screen and the CSV are
|
||||
* built from the report the bank generated, not from a second calculation that could disagree with
|
||||
* it.
|
||||
*
|
||||
* <p>
|
||||
* A connected-group line has no single transaction behind it - it is a month's worth of smaller
|
||||
* cash movements by one customer that together cross the threshold - so its transaction id, account
|
||||
* and date are null, and {@code connectedGroup} says why.
|
||||
*/
|
||||
public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId,
|
||||
Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction,
|
||||
BigDecimal amount, boolean connectedGroup,
|
||||
/**
|
||||
* Account facts the Account_Detail file reports. They were being filled in with a constant
|
||||
* opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement
|
||||
* about the customer's account that nobody had checked.
|
||||
*/
|
||||
LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus,
|
||||
/** Customer identifiers the Account_Person_Relation file reports. */
|
||||
String ckycNumber, String clientExternalId, boolean individual) implements Serializable {}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
/**
|
||||
* Which way the money moved, for rules written about one direction only.
|
||||
*
|
||||
* <p>
|
||||
* Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised
|
||||
* "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one
|
||||
* transaction were of that kind, and an analyst reading them cannot tell which one is real.
|
||||
*/
|
||||
public enum AmlDirection {
|
||||
|
||||
/** Money into the customer's account: a deposit, or a loan disbursal. */
|
||||
CREDIT,
|
||||
/** Money out of it: a withdrawal, or a loan repayment. */
|
||||
DEBIT,
|
||||
/** Not established — the rule's own direction filter, if it has one, then does not apply. */
|
||||
UNKNOWN;
|
||||
|
||||
public boolean isCredit() {
|
||||
return this == CREDIT;
|
||||
}
|
||||
|
||||
public boolean isDebit() {
|
||||
return this == DEBIT;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
/**
|
||||
* The kind of customer a rule is written about, for rules whose subject is the customer rather than
|
||||
* the transaction.
|
||||
*
|
||||
* <p>
|
||||
* Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity
|
||||
* in their own title, but nothing checked it: each was matched on amount alone and so fired on every
|
||||
* ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious
|
||||
* reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts,
|
||||
* and the one real politically exposed person is then missed among them.
|
||||
*/
|
||||
public enum AmlSubjectScope {
|
||||
|
||||
/** Politically exposed person, their relative or close associate. */
|
||||
PEP,
|
||||
|
||||
/** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */
|
||||
CHARITABLE_ORG,
|
||||
|
||||
/**
|
||||
* Dealers in precious metal and stones and the other businesses RBI names as higher risk,
|
||||
* where a large transaction is worth reporting that would be routine for anyone else.
|
||||
*/
|
||||
HIGH_RISK_BUSINESS,
|
||||
|
||||
/**
|
||||
* A recently opened account. A large transaction through an account that has barely been used
|
||||
* is the classic mule pattern; the same transaction on a decade-old account is not remarkable.
|
||||
*/
|
||||
NEW_ACCOUNT;
|
||||
|
||||
public static AmlSubjectScope fromRule(final String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return AmlSubjectScope.valueOf(value.trim().toUpperCase());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
// An unrecognised scope must not silently widen the rule back to everyone, so the
|
||||
// caller treats null-from-blank and this case differently.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -19,7 +19,9 @@
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
@@ -45,9 +47,43 @@ public final class AmlTransactionContext {
|
||||
* rules are simply skipped for this context rather than guessed at.
|
||||
*/
|
||||
private final BigDecimal windowAggregateAmount;
|
||||
/**
|
||||
* Which way the money moved. A rule written about withdrawals must not fire on a deposit, and
|
||||
* "repayment of loan in cash" is not the same event as a disbursal.
|
||||
*/
|
||||
private final AmlDirection direction;
|
||||
/**
|
||||
* How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a
|
||||
* pattern of MANY small transactions, so the count is what separates it from one large one:
|
||||
* a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads.
|
||||
*/
|
||||
private final int windowTransactionCount;
|
||||
/**
|
||||
* Every qualifying amount in the window, so a rule can count only what falls inside its own
|
||||
* band. A single count cannot serve every rule: this client's window held four deposits — three
|
||||
* small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule
|
||||
* whose band none of the four sat in. Structuring is several transactions of a similar size,
|
||||
* and only the amounts themselves can say whether that happened.
|
||||
*/
|
||||
private final List<BigDecimal> windowAmounts;
|
||||
|
||||
/**
|
||||
* When the account this transaction moved through was opened. The "large transaction on a new
|
||||
* account" rule is about the account rather than the customer, and an old customer can open a
|
||||
* new account. Null where the caller could not establish it, in which case the customer's own
|
||||
* activation date stands in.
|
||||
*/
|
||||
private final LocalDate subjectOpenedOn;
|
||||
|
||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
||||
this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN,
|
||||
0, List.of(), null);
|
||||
}
|
||||
|
||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction,
|
||||
int windowTransactionCount, List<BigDecimal> windowAmounts, LocalDate subjectOpenedOn) {
|
||||
this.transactionType = transactionType;
|
||||
this.transactionId = transactionId;
|
||||
this.client = client;
|
||||
@@ -56,6 +92,26 @@ public final class AmlTransactionContext {
|
||||
this.occurredOn = occurredOn;
|
||||
this.cash = cash;
|
||||
this.windowAggregateAmount = windowAggregateAmount;
|
||||
this.direction = direction;
|
||||
this.windowTransactionCount = windowTransactionCount;
|
||||
this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts);
|
||||
this.subjectOpenedOn = subjectOpenedOn;
|
||||
}
|
||||
|
||||
public AmlDirection getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public int getWindowTransactionCount() {
|
||||
return windowTransactionCount;
|
||||
}
|
||||
|
||||
public List<BigDecimal> getWindowAmounts() {
|
||||
return windowAmounts;
|
||||
}
|
||||
|
||||
public LocalDate getSubjectOpenedOn() {
|
||||
return subjectOpenedOn;
|
||||
}
|
||||
|
||||
public AmlTransactionType getTransactionType() {
|
||||
|
||||
+23
-6
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
|
||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
||||
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||
@@ -113,12 +114,22 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
||||
}
|
||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
||||
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
|
||||
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
// Same side of the ledger as the transaction being observed: a deposit is not part of a
|
||||
// withdrawal pattern, so the two are aggregated separately.
|
||||
final List<SavingsAccountTransaction> sameDirection = recent.stream()
|
||||
.filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList();
|
||||
// Each amount is carried through, not just the total: a rule has to be able to count what
|
||||
// falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or
|
||||
// more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be
|
||||
// reported as structuring.
|
||||
final List<BigDecimal> windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList();
|
||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
||||
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate,
|
||||
depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts,
|
||||
transaction.getSavingsAccount().getActivationDate());
|
||||
try {
|
||||
ruleEvaluationService.evaluate(context);
|
||||
} catch (final RuntimeException e) {
|
||||
@@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
||||
}
|
||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
||||
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
|
||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
|
||||
final List<BigDecimal> windowAmounts = repayments.stream()
|
||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList();
|
||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// A repayment takes money from the customer; a disbursal gives it to them. Rules such as
|
||||
// "repayment of loan in cash" are about the first and must not fire on the second.
|
||||
final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT;
|
||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
||||
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction,
|
||||
repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate());
|
||||
try {
|
||||
ruleEvaluationService.evaluate(context);
|
||||
} catch (final RuntimeException e) {
|
||||
|
||||
+9
@@ -20,8 +20,17 @@ package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
|
||||
public interface AmlCtrReportReadPlatformService {
|
||||
|
||||
List<AmlCtrReportData> retrieveAll();
|
||||
|
||||
/**
|
||||
* The reportable lines, resolved to the account, customer and branch a filing names.
|
||||
*
|
||||
* @param reportId
|
||||
* one month's report, or null for every generated month
|
||||
*/
|
||||
List<AmlCtrReportLineData> retrieveLines(Long reportId);
|
||||
}
|
||||
|
||||
+59
@@ -18,13 +18,16 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -33,7 +36,40 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional(readOnly = true)
|
||||
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
||||
|
||||
/**
|
||||
* Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash
|
||||
* moved, so the line says so rather than leaving the reader to infer it from the amount.
|
||||
*/
|
||||
private static final String LINES_SQL = """
|
||||
SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id,
|
||||
l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group,
|
||||
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
||||
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
||||
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
||||
sa.activatedon_date AS account_opened_on,
|
||||
sa.closedon_date AS account_closed_on,
|
||||
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
||||
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
||||
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
||||
WHEN 800 THEN 'MATURED' END AS account_status,
|
||||
k.ckyc_number AS ckyc_number,
|
||||
-- The customer's own reference for the filing: CKYC number if the record carries
|
||||
-- one, else the CIF the bank knows them by. Never blank, because a relation row
|
||||
-- that identifies nobody cannot be reconciled against the customer card.
|
||||
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
||||
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
||||
FROM m_aml_ctr_report_line l
|
||||
JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id
|
||||
JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS'
|
||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
""";
|
||||
|
||||
private final AmlCtrReportRepository repository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<AmlCtrReportData> retrieveAll() {
|
||||
@@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
||||
.map(this::mapToData).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AmlCtrReportLineData> retrieveLines(final Long reportId) {
|
||||
final StringBuilder sql = new StringBuilder(LINES_SQL);
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (reportId != null) {
|
||||
sql.append(" WHERE l.ctr_report_id = ? ");
|
||||
args.add(reportId);
|
||||
}
|
||||
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
|
||||
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
|
||||
|
||||
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
|
||||
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
|
||||
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
|
||||
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
|
||||
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||
rs.getBoolean("individual")), args.toArray());
|
||||
}
|
||||
|
||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
||||
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
||||
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
||||
|
||||
+118
-2
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
@@ -54,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
private final AmlSubjectScopeResolver subjectScopeResolver;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -81,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (!appliesToThisTransaction(rule, context)) {
|
||||
return false;
|
||||
}
|
||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||
if (threshold == null) {
|
||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||
@@ -91,14 +96,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
case THRESHOLD:
|
||||
return context.getAmount().compareTo(threshold) >= 0;
|
||||
case STRUCTURING:
|
||||
return isStructuringHit(rule, context, threshold);
|
||||
case VELOCITY:
|
||||
return context.getWindowAggregateAmount() != null
|
||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||
return isVelocityHit(rule, context, threshold);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule is about this transaction at all, before any amount is compared.
|
||||
*
|
||||
* <p>
|
||||
* Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000
|
||||
* cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low
|
||||
* cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about
|
||||
* transactions this was not. An analyst who cannot tell which of ten alerts is real stops
|
||||
* reading them, and that is how a real one gets missed.
|
||||
*/
|
||||
private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
// A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by
|
||||
// definition and is not what CTR exists to catch.
|
||||
if (rule.isCashOnly() && !context.isCash()) {
|
||||
return false;
|
||||
}
|
||||
// Direction, where the rule names one. UNKNOWN means the caller could not establish it, so
|
||||
// the filter is not applied rather than guessed at.
|
||||
final String ruleDirection = rule.getDirection();
|
||||
if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN
|
||||
&& !ruleDirection.equalsIgnoreCase(context.getDirection().name())) {
|
||||
return false;
|
||||
}
|
||||
// And who the rule is about, for rules named after an entity rather than an amount.
|
||||
// PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing
|
||||
// here asked whether that customer was politically exposed.
|
||||
return subjectScopeResolver.matches(rule.getSubjectScope(), context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structuring: many transactions each deliberately kept below a reporting threshold, summing
|
||||
* to something that would have been reportable had it been done at once.
|
||||
*
|
||||
* <p>
|
||||
* Two things separate it from one large transaction, and neither was being checked. The count:
|
||||
* a pattern needs several transactions, so a single deposit is never structuring however large.
|
||||
* And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000
|
||||
* deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what
|
||||
* that rule used to report it as.
|
||||
*/
|
||||
private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||
if (context.getWindowAggregateAmount() == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||
return false;
|
||||
}
|
||||
// Counted and summed over the rule's own band, not over the window as a whole. A window
|
||||
// holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a
|
||||
// ₹10,00,000 total on the raw figures, and that is how one large deposit came to be
|
||||
// reported as structuring: none of the four was in the band the rule describes.
|
||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||
return false;
|
||||
}
|
||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Velocity: an unusual number of transactions in a short window, whatever each one is worth.
|
||||
*
|
||||
* <p>
|
||||
* Like structuring it needs a count — one transaction is not a velocity — but unlike structuring
|
||||
* the individual amounts are not confined to a band, since the concern is the frequency itself.
|
||||
*/
|
||||
private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||
if (context.getWindowAggregateAmount() == null) {
|
||||
return false;
|
||||
}
|
||||
// Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run
|
||||
// of small ones, and a single ₹12,00,000 is not an instance of it however often the account
|
||||
// is used.
|
||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||
return false;
|
||||
}
|
||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||
return false;
|
||||
}
|
||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this transaction sits inside the band the rule describes.
|
||||
*
|
||||
* <p>
|
||||
* A rule with no band stated matches any amount, which is how these rules behaved before the
|
||||
* columns existed and is what a rule that only names a window total still means.
|
||||
*/
|
||||
private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) {
|
||||
final BigDecimal min = rule.getMinLimitAmount();
|
||||
final BigDecimal max = rule.getMaxLimitAmount();
|
||||
if (min != null && amount.compareTo(min) < 0) {
|
||||
return false;
|
||||
}
|
||||
return max == null || amount.compareTo(max) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether enough transactions have happened in the window to call this a pattern.
|
||||
*
|
||||
* <p>
|
||||
* A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3,
|
||||
* because two large deposits in a month is ordinary business and RBI's own wording is
|
||||
* "numerous".
|
||||
*/
|
||||
private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) {
|
||||
final Integer minimum = rule.getMinTransactionCount();
|
||||
return minimum == null || minimum <= 1 || countInBand >= minimum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
|
||||
* written about.
|
||||
*
|
||||
* <p>
|
||||
* Everything here is read from what the customer record already holds - the client type chosen at
|
||||
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
|
||||
* because the amount is what the rule's threshold is already for.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AmlSubjectScopeResolver {
|
||||
|
||||
/**
|
||||
* Client types that make an account a charitable/not-for-profit one for the purposes of the
|
||||
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
|
||||
* per-tenant and these are seeded per tenant.
|
||||
*/
|
||||
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
|
||||
|
||||
/**
|
||||
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
|
||||
* worth a second look that the same amount elsewhere would not be.
|
||||
*/
|
||||
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
|
||||
"DEALER IN PRECIOUS METALS", "CASINO");
|
||||
|
||||
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
|
||||
private static final int NEW_ACCOUNT_DAYS = 180;
|
||||
|
||||
private static final int HIGH_RISK_KYC_CATEGORY = 3;
|
||||
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
|
||||
/**
|
||||
* Whether a rule carrying this scope should look at this transaction at all.
|
||||
*
|
||||
* <p>
|
||||
* A rule with no scope is about the transaction rather than the customer and applies to
|
||||
* everyone, which is how these rules behaved before the column existed.
|
||||
*/
|
||||
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
|
||||
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
|
||||
if (scope == null) {
|
||||
return true;
|
||||
}
|
||||
final Client client = context.getClient();
|
||||
if (client == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (scope) {
|
||||
case PEP -> isPoliticallyExposed(client);
|
||||
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
|
||||
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
|
||||
case NEW_ACCOUNT -> isNewAccount(context);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
|
||||
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
|
||||
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
|
||||
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
|
||||
* report everyone.
|
||||
*/
|
||||
private boolean isPoliticallyExposed(final Client client) {
|
||||
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|
||||
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
|
||||
return true;
|
||||
}
|
||||
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
|
||||
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
|
||||
}
|
||||
|
||||
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
|
||||
final String type = client.clientType() == null ? null : client.clientType().getLabel();
|
||||
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
|
||||
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured from the customer's activation date against the business date, not the wall clock,
|
||||
* so a back-dated test and a back-dated correction both read the age the books read.
|
||||
*/
|
||||
private boolean isNewAccount(final AmlTransactionContext context) {
|
||||
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
|
||||
: context.getClient().getActivationDate();
|
||||
if (openedOn == null) {
|
||||
return false;
|
||||
}
|
||||
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final String normalised = value.trim().toUpperCase();
|
||||
return wanted.contains(normalised);
|
||||
}
|
||||
|
||||
private boolean codeValueMatches(final String value, final String token) {
|
||||
return value != null && value.trim().toUpperCase().contains(token);
|
||||
}
|
||||
}
|
||||
+54
-11
@@ -37,11 +37,13 @@ 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;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -71,13 +73,6 @@ public class BranchDayRegisterApiResource {
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||
// never corrected -- without this same filter here, the close-preview's counted-in/counted-out
|
||||
// figures would include that phantom amount, on top of the real cash the branch actually counted.
|
||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -196,28 +191,32 @@ public class BranchDayRegisterApiResource {
|
||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||
//
|
||||
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||
// timestamp is not.
|
||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
||||
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
||||
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
|
||||
@@ -247,11 +246,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 +524,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,
|
||||
|
||||
+3
-3
@@ -87,9 +87,9 @@ public class CashierDepositRequestApiResource {
|
||||
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
||||
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
||||
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
||||
// without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so
|
||||
// a request still sitting PENDING continues to count in cashier-position reports -- the cash is
|
||||
// physically in the drawer already, approval or not.
|
||||
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
|
||||
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
|
||||
// -- the cash is physically in the drawer already, approval or not.
|
||||
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
+3
-9
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -47,13 +48,6 @@ public class CashReconciliationApiResource {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||
// never corrected -- without this same filter here, this endpoint (unlike the cashier-position
|
||||
// report) would count that phantom figure into a cashier's expected cash.
|
||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||
|
||||
/**
|
||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||
* Returns denomination-wise expected balance for a cashier.
|
||||
@@ -96,7 +90,7 @@ public class CashReconciliationApiResource {
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
@@ -126,7 +120,7 @@ public class CashReconciliationApiResource {
|
||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
|
||||
+39
-14
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -59,18 +60,14 @@ public class CashReportApiResource {
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by
|
||||
* itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those
|
||||
* rows are genuine cash that has to keep counting. Request ids and transaction ids are both
|
||||
* small; a millisecond epoch is thirteen digits and cannot be either. So anything at or above
|
||||
* 10^12 is rejected as not-an-id, and everything smaller is kept.
|
||||
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
|
||||
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
|
||||
* reads the same on all four.
|
||||
*/
|
||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||
|
||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
||||
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
||||
@@ -83,8 +80,9 @@ public class CashReportApiResource {
|
||||
// everything counted after it) out of the cashier's position.
|
||||
//
|
||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id --
|
||||
// is untouched by this test and still handled by the id-magnitude test above.
|
||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
|
||||
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
|
||||
// CountedCashLedger's rule above.
|
||||
+ "AND NOT EXISTS ("
|
||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
||||
+ " WHERE mt.id = std.txn_id"
|
||||
@@ -1341,21 +1339,42 @@ public class CashReportApiResource {
|
||||
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
||||
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
||||
|
||||
// 2. Paged data query
|
||||
final String dataSql = "SELECT "
|
||||
// 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
|
||||
// entry ever has a denomination breakdown for -- so on its own it names only the cash side
|
||||
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
|
||||
// entry actually moved money between. Every voucher raised through VoucherService (both this
|
||||
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
|
||||
// the same table the My Work register reads its own debit/credit columns from -- joined here
|
||||
// by voucher_no so this report shows the identical pair rather than only the half this
|
||||
// table's own denomination row happens to know about.
|
||||
final String baseSql = "SELECT "
|
||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
||||
+ " std.account_no AS ac_no, "
|
||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
||||
+ " MAX(std.txn_date) AS txn_date, "
|
||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||
+ " MAX(std.created_at) AS sort_key, "
|
||||
+ " MAX(std.officer_username) AS teller_name, "
|
||||
+ " MAX(std.payment_type) AS payment_type, "
|
||||
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
||||
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
||||
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
||||
+ baseGroupSql
|
||||
+ "ORDER BY MAX(std.created_at) "
|
||||
+ baseGroupSql;
|
||||
|
||||
final String dataSql = "SELECT b.*, "
|
||||
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
|
||||
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
|
||||
+ "FROM (" + baseSql + ") b "
|
||||
+ "LEFT JOIN LATERAL ("
|
||||
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'"
|
||||
+ " ORDER BY line_no LIMIT 1) dr ON TRUE "
|
||||
+ "LEFT JOIN LATERAL ("
|
||||
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'"
|
||||
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
|
||||
+ "ORDER BY b.sort_key "
|
||||
+ "LIMIT ? OFFSET ?";
|
||||
|
||||
// Append limit and offset to params
|
||||
@@ -1376,6 +1395,12 @@ public class CashReportApiResource {
|
||||
row.put("txnSource", rs.getString("txn_source"));
|
||||
row.put("dr", rs.getBigDecimal("dr_amount"));
|
||||
row.put("cr", rs.getBigDecimal("cr_amount"));
|
||||
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
|
||||
// was raised before m_voucher_line existed, or belongs to no register that writes one.
|
||||
row.put("debitParty", rs.getString("debit_party"));
|
||||
row.put("debitAccount", rs.getString("debit_account"));
|
||||
row.put("creditParty", rs.getString("credit_party"));
|
||||
row.put("creditAccount", rs.getString("credit_account"));
|
||||
return row;
|
||||
}, pagedParams);
|
||||
|
||||
|
||||
+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, "
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.denominationmaster.service;
|
||||
|
||||
/**
|
||||
* The one definition of which {@code m_savings_txn_denomination} rows are real counted cash.
|
||||
*
|
||||
* <p>
|
||||
* Written here once because it is read from four places -- the branch day register's close preview,
|
||||
* the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and
|
||||
* those places disagreeing is what makes a drawer hold one figure on one screen and another on the
|
||||
* next. A change to the rule belongs in this file and nowhere else.
|
||||
*
|
||||
* <h3>What the rule has to separate</h3>
|
||||
*
|
||||
* <p>
|
||||
* The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes
|
||||
* a different kind of id into it:
|
||||
*
|
||||
* <ul>
|
||||
* <li>a savings, current or deposit transaction writes its own transaction id -- always small;
|
||||
* <li>a cashier deposit request writes its request id plus a fixed offset -- also small;
|
||||
* <li>a Demand Draft issue writes its own request id -- small;
|
||||
* <li>a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into
|
||||
* this column (see {@code CashierGlDenominationApiResource}, matching the encoding
|
||||
* {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very
|
||||
* large, but a hex string beginning with low digits can land anywhere;
|
||||
* <li>and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to
|
||||
* no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are
|
||||
* carried forward for ever, and one database held ₹7.29 lakh of them.
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Why this is not a magnitude test</h3>
|
||||
*
|
||||
* <p>
|
||||
* A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject
|
||||
* anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows.
|
||||
* A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661,
|
||||
* which sits squarely inside the 13-digit band a millisecond epoch occupies.
|
||||
*
|
||||
* <p>
|
||||
* What actually separates them is that a millisecond epoch stamped at write time decodes back to
|
||||
* approximately the day the row itself belongs to, while a hex reference that happens to land in the
|
||||
* same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026
|
||||
* transaction date. So the test reads the id as a date and asks whether it lands on the row's own
|
||||
* day. That is self-maintaining: it needs no constant moved forward as real timestamps grow.
|
||||
*/
|
||||
public final class CountedCashLedger {
|
||||
|
||||
private CountedCashLedger() {}
|
||||
|
||||
/** Widest gap still read as "this id is the moment the row was written", in days. */
|
||||
private static final int SAME_DAY_TOLERANCE_DAYS = 7;
|
||||
|
||||
/**
|
||||
* SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over
|
||||
* the alias {@code std} ({@code m_savings_txn_denomination}).
|
||||
*
|
||||
* <p>
|
||||
* The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside
|
||||
* PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value
|
||||
* this column can hold can overflow the comparison.
|
||||
*
|
||||
* <p>
|
||||
* Padded with a space at each end so it reads the same spliced into a text block -- which strips
|
||||
* the trailing space off the {@code AND } it follows -- as concatenated onto a plain string.
|
||||
*/
|
||||
public static final String REAL_TXN_ID = " (std.txn_id IS NULL"
|
||||
+ " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000"
|
||||
+ " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL"
|
||||
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
|
||||
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
|
||||
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
|
||||
}
|
||||
+4
-12
@@ -40,14 +40,6 @@ public class DenominationBreakdownWriter {
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||
// never corrected -- without this same filter here, a cashier's computed stock (and the
|
||||
// insufficient-stock check that guards a withdrawal against it) would count that phantom figure
|
||||
// as real notes and coins in the drawer.
|
||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -294,7 +286,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||
+ " FROM m_savings_txn_denomination std"
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
@@ -318,7 +310,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||
+ " FROM m_savings_txn_denomination std"
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
@@ -415,7 +407,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||
+ " FROM m_savings_txn_denomination std"
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
@@ -439,7 +431,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||
+ " FROM m_savings_txn_denomination std"
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
|
||||
+28
-17
@@ -45,6 +45,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -94,12 +96,11 @@ public class DividendClaimRequestApiResource {
|
||||
private final VoucherService voucherService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
/** The dividend GL accounts, as configured on Accounting > Financial Activity Mappings. */
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
|
||||
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
|
||||
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
|
||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||
|
||||
@@ -492,23 +493,31 @@ public class DividendClaimRequestApiResource {
|
||||
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
|
||||
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
|
||||
*/
|
||||
/**
|
||||
* The DD Payable liability a dividend draft is raised against.
|
||||
*
|
||||
* m_sponsor_dd_gl_mapping holds a single global row — it was keyed by payment type until 0349 dropped that column,
|
||||
* and the read side reports it as "Global" — so there is one mapping for every sponsor bank, and the first row is
|
||||
* the only row. Kept as its own method so this reads as a deliberate choice rather than an arbitrary LIMIT 1, and
|
||||
* so it fails loudly if the mapping has not been configured at all.
|
||||
*/
|
||||
private Long ddPayableAccount() {
|
||||
final Long glAccountId = jdbcTemplate
|
||||
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (glAccountId == null) {
|
||||
throw new IllegalStateException("No Sponsor DD Payable GL account is configured. Set it under "
|
||||
+ "Organization > Sponsor DD GST / GL Settings before approving a dividend claim.");
|
||||
}
|
||||
return glAccountId;
|
||||
}
|
||||
|
||||
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
|
||||
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
|
||||
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long dividendPaidAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_DIVIDEND_PAID)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long ddPayableAccountId = jdbcTemplate
|
||||
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || ddPayableAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||
final Long dividendPaidAccountId = dividendGlAccounts.dividendPaidAccountOrNull();
|
||||
final Long ddPayableAccountId = ddPayableAccount();
|
||||
|
||||
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
|
||||
final String transactionId = "DIVDD" + claimId;
|
||||
@@ -587,6 +596,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,
|
||||
|
||||
+14
-5
@@ -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 = """
|
||||
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
|
||||
""";
|
||||
}
|
||||
|
||||
+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, "
|
||||
|
||||
+7
-15
@@ -22,6 +22,8 @@ import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
@@ -39,11 +41,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@RequiredArgsConstructor
|
||||
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
|
||||
/** Retained profit, where a dividend nobody claimed returns to. */
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
@@ -53,6 +50,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -130,16 +128,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
||||
* declaration-time entries.
|
||||
*/
|
||||
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
||||
final Long payableDividendsAccountId = this.jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long bankProfitAccountId = this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
|
||||
.orElse(null);
|
||||
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long payableDividendsAccountId = this.dividendGlAccounts.payableDividendsAccount();
|
||||
final Long bankProfitAccountId = this.dividendGlAccounts.retainedProfitAccount();
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
@@ -156,6 +146,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
-2
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.shareaccounts.start;
|
||||
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
@@ -115,9 +116,9 @@ public class ShareAccountsConfiguration {
|
||||
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
||||
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
|
||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate, DividendGlAccountResolver dividendGlAccounts) {
|
||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
||||
context, jdbcTemplate);
|
||||
context, jdbcTemplate, dividendGlAccounts);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+16
-6
@@ -39,6 +39,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -77,8 +78,11 @@ public class AllProductsDividendApiResource {
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||
private static final int DIVIDEND_STATUS_INITIATED = 100;
|
||||
|
||||
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
/**
|
||||
* The account a dividend is declared against, once year-end closing has landed the profit in it — configured as
|
||||
* the Distributable Profit financial activity rather than found by a hardcoded GL code.
|
||||
*/
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
/**
|
||||
* A dividend is a rate on each member's own paid-up capital, and nothing else.
|
||||
@@ -187,7 +191,7 @@ public class AllProductsDividendApiResource {
|
||||
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
|
||||
out.put("ratePercentage", ratePercentage);
|
||||
out.put("totalWeightedCapital", totalWeightedCapital);
|
||||
out.put("profitGlCode", PROFIT_GL_CODE);
|
||||
out.put("profitGlCode", distributableProfitGlCode());
|
||||
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
|
||||
// visible on screen before submission — the command layer enforces the same ceiling, and an
|
||||
// officer should not discover it only when the declaration is rejected.
|
||||
@@ -428,9 +432,9 @@ public class AllProductsDividendApiResource {
|
||||
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(fyStart),
|
||||
java.sql.Date.valueOf(fyEnd));
|
||||
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
||||
}
|
||||
}
|
||||
@@ -494,6 +498,12 @@ public class AllProductsDividendApiResource {
|
||||
}
|
||||
|
||||
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
|
||||
/** The configured Distributable Profit account's GL code, for display on the declaration screen. */
|
||||
private String distributableProfitGlCode() {
|
||||
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class,
|
||||
dividendGlAccounts.distributableProfitAccount());
|
||||
}
|
||||
|
||||
private String financialYearOf(final String isoDate) {
|
||||
final LocalDate date = LocalDate.parse(isoDate);
|
||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
|
||||
+39
-96
@@ -29,6 +29,8 @@ import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||
import org.apache.fineract.commands.domain.CommandProcessingResultType;
|
||||
import org.apache.fineract.commands.domain.CommandSource;
|
||||
@@ -88,10 +90,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
private final CommandSourceRepository commandSourceRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
||||
// accounting module into this constructor just for two int constants.
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
|
||||
/**
|
||||
* How many financial years of dividend a member may still come and claim.
|
||||
*
|
||||
@@ -100,19 +98,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
* money is recognised as income rather than sitting as a liability nobody will ever call on.
|
||||
*/
|
||||
private static final int CLAIMABLE_FINANCIAL_YEARS = 3;
|
||||
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
||||
|
||||
/**
|
||||
* The year's closed-out profit a dividend is declared out of; kept in step with
|
||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
||||
* The dividend GL accounts, read from the Financial Activity Mappings a society configures.
|
||||
*
|
||||
* These were hardcoded GL codes (BANKPROFITND01 / BANKPROFIT01) until the whole dividend lifecycle was made
|
||||
* configurable — see {@link DividendGlAccountResolver} for why the lookup throws on a missing mapping instead of
|
||||
* posting nothing.
|
||||
*/
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
|
||||
/**
|
||||
* Where profit settles once a declaration has been approved — the undistributed remainder and the rounding
|
||||
* residue. Nothing here ever enters a later dividend calculation.
|
||||
*/
|
||||
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||
@@ -301,11 +295,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
return;
|
||||
}
|
||||
|
||||
final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE);
|
||||
final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
||||
if (distributableAccountId == null || settledAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount();
|
||||
final Long settledAccountId = dividendGlAccounts.retainedProfitAccount();
|
||||
|
||||
final String financialYear = financialYearOf(periodEndDate);
|
||||
// Dated to the day of approval, not the period end. Closing off the remainder is an act of
|
||||
@@ -357,14 +348,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue();
|
||||
final BigDecimal residue = declaredAmount.subtract(allocatedAmount);
|
||||
|
||||
// Swept to the settled account, not back to the pool it came from: the residue belongs to the
|
||||
// year that was just declared, and returning it to the distributable balance would put it
|
||||
// straight back into what the next declaration measures.
|
||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
// Resolved here so a missing mapping is reported while the declaration can still be abandoned, rather than
|
||||
// first surfacing at approval. Never back to the distributable pool it came from: the residue belongs to the
|
||||
// year just declared, and returning it there would put it straight into what the next declaration measures.
|
||||
dividendGlAccounts.dividendRoundOffAccount();
|
||||
dividendGlAccounts.payableDividendsAccount();
|
||||
|
||||
// A residue of zero still gets its row: "nothing was left over" is itself the answer to the
|
||||
// audit question, and its absence would be indistinguishable from the sweep never running.
|
||||
@@ -411,26 +399,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
return;
|
||||
}
|
||||
|
||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
// Left unswept rather than forced through: swept_to_profit_date stays null so the residue is
|
||||
// still identifiable and can be swept once the GL setup is completed.
|
||||
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
// The round-off account when the society has mapped one, retained profit otherwise — see
|
||||
// DividendGlAccountResolver#dividendRoundOffAccount for why this one resolves rather than throwing.
|
||||
final Long roundOffAccountId = dividendGlAccounts.dividendRoundOffAccount();
|
||||
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||
|
||||
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
|
||||
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||
final String transactionId = "DIVROUND" + dividendPayOutId;
|
||||
|
||||
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
|
||||
// that will never be paid to a member and returns it to retained profit.
|
||||
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId,
|
||||
// that will never be paid to a member, because no member's share divided into it evenly.
|
||||
postJournalEntryPair(payableDividendsAccountId, roundOffAccountId, residue, (String) row.get("currency_code"), transactionId,
|
||||
sweptDate,
|
||||
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId);
|
||||
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept — declaration #" + dividendPayOutId);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_share_dividend_rounding_residue
|
||||
@@ -511,9 +493,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(fyStart),
|
||||
java.sql.Date.valueOf(fyEnd));
|
||||
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
||||
}
|
||||
}
|
||||
@@ -543,11 +525,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
}
|
||||
|
||||
|
||||
private Long glAccountIdByCode(final String glCode) {
|
||||
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaring a dividend charges it against the society's retained profit and creates the obligation to members.
|
||||
*
|
||||
@@ -564,24 +541,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
* year-end closing, so the round trip reconciles.
|
||||
*
|
||||
* Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which
|
||||
* expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting
|
||||
* Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by
|
||||
* incomplete GL setup.
|
||||
* expects a loan/savings/share *transaction* rather than a standalone declaration event.
|
||||
*
|
||||
* Both accounts must be mapped; there is no longer a degraded path that posts straight to Dividend Payable when
|
||||
* Reserve is absent. Skipping the appropriation would put a liability to members on the books at declaration, which
|
||||
* is exactly what routing through Reserve exists to prevent — so an unmapped Reserve is a setup error to be
|
||||
* reported, not a variation to be absorbed.
|
||||
*/
|
||||
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount,
|
||||
final String financialYear) {
|
||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE);
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long reserveAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long bankProfitAccountId = dividendGlAccounts.distributableProfitAccount();
|
||||
final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||
@@ -591,12 +561,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
// across years, and without the year on the line the balances only ever give a running total.
|
||||
final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId;
|
||||
|
||||
if (reserveAccountId == null) {
|
||||
postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate,
|
||||
description);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the appropriation posts here. The release to members is a separate act that waits for
|
||||
// approval — see releaseReserveToDividendPayable. Posting both at declaration would put a
|
||||
// liability to members on the books before anyone had approved paying them.
|
||||
@@ -619,20 +583,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
* Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside.
|
||||
*/
|
||||
private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) {
|
||||
final Long reserveAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
||||
.stream().findFirst().orElse(null);
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
// With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend
|
||||
// Payable, so the liability is on the books and there is nothing left to release.
|
||||
if (reserveAccountId == null || payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
|
||||
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||
|
||||
// Read back from the row rather than the entity: the amount and product are not exposed as
|
||||
// getters, and this matches how closeOffUndistributedProfit reads the same declaration.
|
||||
@@ -660,6 +612,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);
|
||||
@@ -745,11 +699,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
return;
|
||||
}
|
||||
|
||||
final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS);
|
||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
||||
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||
final Long bankProfitAccountId = dividendGlAccounts.retainedProfitAccount();
|
||||
|
||||
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||
for (final Map<String, Object> lock : expired) {
|
||||
@@ -769,14 +720,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
}
|
||||
}
|
||||
|
||||
/** A GL account mapped to a financial activity, or null when the society has not configured it. */
|
||||
private Long glAccountIdByFinancialActivity(final int financialActivityType) {
|
||||
return jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
financialActivityType)
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private void makeDividendsClaimable(final Long payOutDetailId) {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
||||
|
||||
+5
-2
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.shareproducts.starter;
|
||||
|
||||
import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
@@ -117,11 +118,13 @@ public class ShareProductsConfiguration {
|
||||
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
|
||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate,
|
||||
DividendGlAccountResolver dividendGlAccounts) {
|
||||
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
||||
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
||||
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate,
|
||||
dividendGlAccounts);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+46
@@ -52,11 +52,13 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDRevokeRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -72,6 +74,7 @@ public class SponsorDDApiResource {
|
||||
private final SponsorDDReconciliationService reconciliationService;
|
||||
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
||||
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
@@ -426,6 +429,49 @@ public class SponsorDDApiResource {
|
||||
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
||||
}
|
||||
|
||||
// ── Revoking a leaflet spoiled at the printer ──────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("leaflets/{leafletId}/revoke-request")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Request to revoke a spoiled DD and replace it",
|
||||
description = "Clerk (maker) step: the leaflet was ruined at the printer, so its draft must move onto the next available blank. Distinct from a reprint, which re-prints the same leaflet.")
|
||||
public CommandProcessingResult requestRevoke(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.requestRevoke(leafletId, request.getReason()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("revoke-requests")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DD revoke requests",
|
||||
description = "Returns all DD revoke requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.")
|
||||
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(@QueryParam("status") final String status) {
|
||||
return this.readPlatformService.retrieveAllRevokeRequests(status);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("revoke-requests/{revokeRequestId}/approve")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a DD revoke request",
|
||||
description = "Manager/head-cashier (checker) step: marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. No money moves - the customer was already debited and is still owed the same draft.")
|
||||
public CommandProcessingResult approveRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.approveRevokeRequest(revokeRequestId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("revoke-requests/{revokeRequestId}/reject")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reject a DD revoke request",
|
||||
description = "Manager/head-cashier (checker) step: rejects a pending DD revoke request. The leaflet stays issued.")
|
||||
public CommandProcessingResult rejectRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId,
|
||||
final RejectIssueRequestRequest request) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.rejectRevokeRequest(revokeRequestId, request.getRejectionReason()));
|
||||
}
|
||||
|
||||
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
|
||||
|
||||
@POST
|
||||
|
||||
+16
@@ -59,4 +59,20 @@ public class SponsorDDLeafletData implements Serializable {
|
||||
private String approverUsername;
|
||||
/** Set while a correction to this draft is awaiting a manager. */
|
||||
private Long pendingAmendmentRequestId;
|
||||
|
||||
/** Set while a revoke is awaiting a checker, so the register offers it only once. */
|
||||
private Long pendingRevokeRequestId;
|
||||
|
||||
/**
|
||||
* Where a spoiled leaflet's draft went, and which spoiled leaflet a replacement stands in for.
|
||||
*
|
||||
* <p>
|
||||
* Both directions are shown in the register: a revoked leaflet reads as consumed stock with a
|
||||
* pointer to the draft that survived it, and the replacement says what it was issued against.
|
||||
* Without the pair the pad simply comes up short against the sponsor bank's range.
|
||||
*/
|
||||
private Long revokedReplacedByLeafletId;
|
||||
private String revokedReplacedByDdNumber;
|
||||
private Long revokedReplacementOfLeafletId;
|
||||
private String revokedReplacementOfDdNumber;
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A request to replace a leaflet spoiled at the printer.
|
||||
*
|
||||
* <p>
|
||||
* Carries the draft's own details as well as the request's, because a checker approving this is
|
||||
* deciding to consume a blank from limited stock - they need to see whose draft it is and for how
|
||||
* much without opening the register in another tab.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SponsorDDRevokeRequestData implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private Long leafletId;
|
||||
private String ddNumber;
|
||||
private String reason;
|
||||
private String status;
|
||||
|
||||
/** Null until approved: no blank is consumed while the request is still pending. */
|
||||
private Long replacementLeafletId;
|
||||
private String replacementDdNumber;
|
||||
|
||||
/** The draft being moved, so the approval queue can be read on its own. */
|
||||
private String beneficiaryName;
|
||||
private String clientName;
|
||||
private BigDecimal faceValue;
|
||||
private String sponsorBankName;
|
||||
|
||||
private Long makerId;
|
||||
private String makerUsername;
|
||||
private LocalDateTime createdDate;
|
||||
private Long checkerId;
|
||||
private String checkerUsername;
|
||||
private LocalDateTime checkerDate;
|
||||
private String rejectionReason;
|
||||
}
|
||||
+4
@@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
|
||||
public interface SponsorDDReadPlatformService {
|
||||
|
||||
@@ -59,6 +60,9 @@ public interface SponsorDDReadPlatformService {
|
||||
|
||||
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
||||
|
||||
/** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */
|
||||
Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(String status);
|
||||
|
||||
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
|
||||
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
|
||||
|
||||
|
||||
+92
-6
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -75,6 +76,18 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Series order for a book of drafts.
|
||||
*
|
||||
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 — which handed the
|
||||
* clerk draft 10 immediately after draft 1 and showed the register out of order. Pre-printed
|
||||
* stationery has to be consumed in series, so a wholly numeric series is cast and ordered
|
||||
* numerically; a series carrying a prefix keeps text order, which is already its own sequence.
|
||||
*/
|
||||
private static final String LEAFLET_SERIES_ORDER =
|
||||
"order by case when leaf.dd_number ~ '^[0-9]+$' then cast(leaf.dd_number as bigint) end asc nulls last, "
|
||||
+ "leaf.dd_number asc";
|
||||
|
||||
private static final String LEAFLET_SELECT =
|
||||
"select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, "
|
||||
+ "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, "
|
||||
@@ -83,7 +96,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
||||
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
||||
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
|
||||
+ "amr.id as pending_amendment_request_id "
|
||||
+ "amr.id as pending_amendment_request_id, rvr.id as pending_revoke_request_id, "
|
||||
+ "leaf.revoked_replaced_by_leaflet_id, rby.dd_number as revoked_replaced_by_dd_number, "
|
||||
+ "leaf.revoked_replacement_of_leaflet_id, rof.dd_number as revoked_replacement_of_dd_number "
|
||||
+ "from m_sponsor_dd_leaflet leaf "
|
||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
||||
@@ -94,11 +109,20 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "left join m_appuser approver on approver.id = apr.checker_id "
|
||||
// Lets the registry show that a correction is already awaiting a manager, instead of
|
||||
// offering Edit again and letting a second request be raised over the same draft.
|
||||
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' ";
|
||||
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "
|
||||
// Same reason as the amendment join: the register must not offer Revoke twice over one
|
||||
// spoiled leaflet while a checker still has the first request.
|
||||
+ "left join m_sponsor_dd_revoke_request rvr on rvr.leaflet_id = leaf.id and rvr.status = 'PENDING' "
|
||||
// Both ends of a replacement, so the register can say where a revoked draft went and
|
||||
// what a replacement was issued against.
|
||||
+ "left join m_sponsor_dd_leaflet rby on rby.id = leaf.revoked_replaced_by_leaflet_id "
|
||||
+ "left join m_sponsor_dd_leaflet rof on rof.id = leaf.revoked_replacement_of_leaflet_id ";
|
||||
|
||||
@Override
|
||||
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
||||
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc";
|
||||
// Numeric-aware, for the same reason as retrieveNextAvailableLeaflet: a text sort shows the
|
||||
// register as 1, 10, 2, 3 and the book reads as though it were issued out of order.
|
||||
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? " + LEAFLET_SERIES_ORDER;
|
||||
return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId);
|
||||
}
|
||||
|
||||
@@ -170,11 +194,22 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final String approverUsername = rs.getString("approver_username");
|
||||
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
|
||||
? rs.getLong("pending_amendment_request_id") : null;
|
||||
final Long pendingRevokeRequestId = rs.getObject("pending_revoke_request_id") != null
|
||||
? rs.getLong("pending_revoke_request_id")
|
||||
: null;
|
||||
final Long revokedReplacedByLeafletId = rs.getObject("revoked_replaced_by_leaflet_id") != null
|
||||
? rs.getLong("revoked_replaced_by_leaflet_id")
|
||||
: null;
|
||||
final Long revokedReplacementOfLeafletId = rs.getObject("revoked_replacement_of_leaflet_id") != null
|
||||
? rs.getLong("revoked_replacement_of_leaflet_id")
|
||||
: null;
|
||||
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
||||
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
||||
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
||||
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
||||
approverUserId, approverUsername, pendingAmendmentRequestId);
|
||||
approverUserId, approverUsername, pendingAmendmentRequestId, pendingRevokeRequestId,
|
||||
revokedReplacedByLeafletId, rs.getString("revoked_replaced_by_dd_number"), revokedReplacementOfLeafletId,
|
||||
rs.getString("revoked_replacement_of_dd_number"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +449,54 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
return this.jdbcTemplate.query(sql, new ReprintRequestMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* The request, the draft it is about, and the blank that replaced it.
|
||||
*
|
||||
* <p>
|
||||
* Joined out to the leaflet and the client because approving this consumes a blank from limited
|
||||
* stock: the checker is deciding about a specific customer's draft, not an abstract request id.
|
||||
*/
|
||||
private static final String REVOKE_REQUEST_SELECT =
|
||||
"select rv.id, rv.leaflet_id, leaf.dd_number, rv.reason, rv.status, rv.replacement_leaflet_id, "
|
||||
+ "repl.dd_number as replacement_dd_number, leaf.beneficiary_name, "
|
||||
+ "coalesce(c.display_name, leaf.walkin_purchaser_name) as client_name, leaf.face_value, "
|
||||
+ "sb.name as sponsor_bank_name, rv.maker_id, maker.username as maker_username, rv.created_date, "
|
||||
+ "rv.checker_id, checker.username as checker_username, rv.checker_date, rv.rejection_reason "
|
||||
+ "from m_sponsor_dd_revoke_request rv "
|
||||
+ "join m_sponsor_dd_leaflet leaf on leaf.id = rv.leaflet_id "
|
||||
+ "left join m_sponsor_dd_leaflet repl on repl.id = rv.replacement_leaflet_id "
|
||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||
+ "left join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id "
|
||||
+ "left join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id "
|
||||
+ "join m_appuser maker on maker.id = rv.maker_id "
|
||||
+ "left join m_appuser checker on checker.id = rv.checker_id ";
|
||||
|
||||
@Override
|
||||
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(final String status) {
|
||||
if (status != null && !status.isBlank()) {
|
||||
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "where rv.status = ? order by rv.id desc",
|
||||
new RevokeRequestMapper(), status);
|
||||
}
|
||||
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "order by rv.id desc", new RevokeRequestMapper());
|
||||
}
|
||||
|
||||
private static final class RevokeRequestMapper implements RowMapper<SponsorDDRevokeRequestData> {
|
||||
|
||||
@Override
|
||||
public SponsorDDRevokeRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return new SponsorDDRevokeRequestData(rs.getLong("id"), rs.getLong("leaflet_id"), rs.getString("dd_number"),
|
||||
rs.getString("reason"), rs.getString("status"),
|
||||
rs.getObject("replacement_leaflet_id") != null ? rs.getLong("replacement_leaflet_id") : null,
|
||||
rs.getString("replacement_dd_number"), rs.getString("beneficiary_name"), rs.getString("client_name"),
|
||||
rs.getBigDecimal("face_value"), rs.getString("sponsor_bank_name"), rs.getLong("maker_id"),
|
||||
rs.getString("maker_username"),
|
||||
rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null,
|
||||
rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null, rs.getString("checker_username"),
|
||||
rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null,
|
||||
rs.getString("rejection_reason"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> {
|
||||
@Override
|
||||
public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
@@ -502,7 +585,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
sql += " AND leaf.advice_status = ? ";
|
||||
params.add(adviceStatus);
|
||||
}
|
||||
sql += " ORDER BY leaf.dd_number ASC";
|
||||
sql += " " + LEAFLET_SERIES_ORDER;
|
||||
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
||||
}
|
||||
|
||||
@@ -526,7 +609,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
sql.append("and inv.sponsor_bank_id = ? ");
|
||||
params.add(sponsorBankId);
|
||||
}
|
||||
sql.append("order by inv.id asc, leaf.dd_number asc limit 1");
|
||||
// Oldest book first, then strictly up its own series — see LEAFLET_SERIES_ORDER.
|
||||
sql.append("order by inv.id asc, ");
|
||||
sql.append(LEAFLET_SERIES_ORDER.substring("order by ".length()));
|
||||
sql.append(" limit 1");
|
||||
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
/**
|
||||
* Replacing a draft whose leaflet was spoiled at the printer.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from a reprint, which puts the same draft through the printer again on the same leaflet.
|
||||
* Here the paper itself is ruined: its printed number can never be handed to anyone, so the draft
|
||||
* has to move onto the next blank in the pad. Both leaflets are then accounted for - one spoiled,
|
||||
* one carrying the draft - which is what lets the branch's stock reconcile against the pad the
|
||||
* sponsor bank sent.
|
||||
*
|
||||
* <p>
|
||||
* No money moves. The customer was debited when the draft was issued and is still owed exactly one
|
||||
* draft for exactly that amount; only the instrument changes.
|
||||
*/
|
||||
public interface SponsorDDRevokeRequestWritePlatformService {
|
||||
|
||||
/** Raised by the branch that spoiled the leaflet. Nothing moves until a checker approves. */
|
||||
CommandProcessingResult requestRevoke(Long leafletId, String reason);
|
||||
|
||||
/**
|
||||
* Marks the spoiled leaflet REVOKED and moves its draft onto the next available blank.
|
||||
*
|
||||
* @return the request id, with the replacement leaflet as the sub-entity
|
||||
*/
|
||||
CommandProcessingResult approveRevokeRequest(Long revokeRequestId);
|
||||
|
||||
CommandProcessingResult rejectRevokeRequest(Long revokeRequestId, String rejectionReason);
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SponsorDDRevokeRequestWritePlatformServiceImpl implements SponsorDDRevokeRequestWritePlatformService {
|
||||
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_APPROVED = "APPROVED";
|
||||
private static final String STATUS_REJECTED = "REJECTED";
|
||||
private static final String LEAFLET_AVAILABLE = "AVAILABLE";
|
||||
private static final String LEAFLET_ISSUED = "ISSUED";
|
||||
private static final String LEAFLET_REVOKED = "REVOKED";
|
||||
|
||||
/**
|
||||
* Series order for pre-printed stationery.
|
||||
*
|
||||
* <p>
|
||||
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 - which handed out
|
||||
* draft 10 as the replacement for draft 1 while drafts 2 to 9 sat unused, and left the pad
|
||||
* consumed out of sequence with no way to reconcile it against the sponsor bank's range. A
|
||||
* wholly numeric series is cast and ordered numerically; a series carrying a prefix keeps text
|
||||
* order, which is already its own sequence.
|
||||
*/
|
||||
private static final String SERIES_ORDER =
|
||||
"case when l.dd_number ~ '^[0-9]+$' then cast(l.dd_number as bigint) end asc nulls last, l.dd_number asc";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult requestRevoke(final Long leafletId, final String reason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
|
||||
|
||||
if (!LEAFLET_ISSUED.equals(leaflet.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Only an issued Demand Draft can be revoked. Current status: " + leaflet.get("status"));
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("A reason is required to revoke a spoiled Demand Draft.");
|
||||
}
|
||||
if (!this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_sponsor_dd_revoke_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
|
||||
.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"A revoke request is already pending for this Demand Draft. Wait for it to be approved or rejected.");
|
||||
}
|
||||
|
||||
// Checked at request time as well as at approval: a clerk who has run out of blank drafts
|
||||
// needs to know now, while the customer is still at the counter, rather than after a
|
||||
// checker has been asked to approve something that cannot be carried out.
|
||||
if (findReplacementLeaflet(leafletId) == null) {
|
||||
throw new IllegalStateException("No blank Demand Draft is available to replace this one - "
|
||||
+ "not in this series, elsewhere in this sponsor bank's stock, or with any other sponsor bank. "
|
||||
+ "Add fresh DD inventory before revoking.");
|
||||
}
|
||||
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(con -> {
|
||||
final java.sql.PreparedStatement ps = con.prepareStatement(
|
||||
"INSERT INTO m_sponsor_dd_revoke_request (leaflet_id, reason, status, maker_id, created_date) "
|
||||
+ "VALUES (?, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, leafletId);
|
||||
ps.setString(2, reason);
|
||||
ps.setString(3, STATUS_PENDING);
|
||||
ps.setLong(4, currentUser.getId());
|
||||
ps.setObject(5, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
return new CommandProcessingResultBuilder().withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult approveRevokeRequest(final Long revokeRequestId) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot approve your own revoke request.");
|
||||
}
|
||||
|
||||
final Long spoiledId = ((Number) request.get("leaflet_id")).longValue();
|
||||
final Map<String, Object> spoiled = findLeafletOrThrow(spoiledId);
|
||||
if (!LEAFLET_ISSUED.equals(spoiled.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"This Demand Draft is no longer issued. Current status: " + spoiled.get("status"));
|
||||
}
|
||||
|
||||
// Re-checked here rather than trusted from request time: stock moves, and the blank that was
|
||||
// free when the clerk asked may have been issued to someone else in the meantime.
|
||||
final Map<String, Object> replacement = findReplacementLeaflet(spoiledId);
|
||||
if (replacement == null) {
|
||||
throw new IllegalStateException("No blank Demand Draft is available to replace this one. "
|
||||
+ "Add fresh DD inventory, then approve this request.");
|
||||
}
|
||||
final Long replacementId = ((Number) replacement.get("id")).longValue();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
// The draft moves wholesale onto the new leaflet - same customer, same beneficiary, same
|
||||
// amount. Nothing is recalculated: the customer was debited once, for this draft, and is
|
||||
// owed exactly this draft on whatever paper the branch can actually print.
|
||||
this.jdbcTemplate.update("UPDATE m_sponsor_dd_leaflet SET status = ?, face_value = ?, issued_to_client_id = ?, "
|
||||
+ "beneficiary_name = ?, issuing_branch_id = ?, issued_date = ?, walkin_purchaser_name = ?, "
|
||||
+ "walkin_purchaser_address = ?, walkin_purchaser_aadhaar_masked = ?, "
|
||||
+ "revoked_replacement_of_leaflet_id = ?, lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||
LEAFLET_ISSUED, spoiled.get("face_value"), spoiled.get("issued_to_client_id"),
|
||||
spoiled.get("beneficiary_name"), spoiled.get("issuing_branch_id"), spoiled.get("issued_date"),
|
||||
spoiled.get("walkin_purchaser_name"), spoiled.get("walkin_purchaser_address"),
|
||||
spoiled.get("walkin_purchaser_aadhaar_masked"), spoiledId, currentUser.getId(), now, replacementId);
|
||||
|
||||
// The spoiled leaflet keeps its beneficiary and amount. It is a record of a draft that was
|
||||
// printed and destroyed, not an empty slot, and blanking it would leave the pad short by one
|
||||
// with nothing to say where it went.
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_leaflet SET status = ?, revoked_replaced_by_leaflet_id = ?, "
|
||||
+ "lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||
LEAFLET_REVOKED, replacementId, currentUser.getId(), now, spoiledId);
|
||||
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_revoke_request SET status = ?, replacement_leaflet_id = ?, checker_id = ?, "
|
||||
+ "checker_date = ? WHERE id = ?",
|
||||
STATUS_APPROVED, replacementId, currentUser.getId(), now, revokeRequestId);
|
||||
|
||||
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).withSubEntityId(replacementId).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult rejectRevokeRequest(final Long revokeRequestId, final String rejectionReason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot reject your own revoke request.");
|
||||
}
|
||||
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_revoke_request SET status = ?, checker_id = ?, checker_date = ?, "
|
||||
+ "rejection_reason = ? WHERE id = ?",
|
||||
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, revokeRequestId);
|
||||
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The blank that should carry the draft instead, searched in the order a branch would reach for
|
||||
* one.
|
||||
*
|
||||
* <p>
|
||||
* Nearest first: the next number in the same pad, so the series stays unbroken and the sponsor
|
||||
* bank's stock reconciles against one continuous range. Then anywhere else in the same sponsor
|
||||
* bank's stock - a second pad from the same bank is still that bank's paper and its drafts are
|
||||
* honoured the same way. Only then another sponsor bank, which is a real fallback rather than a
|
||||
* preference: the draft is drawn on a different bank, so the branch would rather not, but a
|
||||
* customer standing at the counter needs a draft more than the branch needs a tidy series.
|
||||
*
|
||||
* <p>
|
||||
* Null when the branch has no blank draft anywhere, which is a stock problem and is reported as
|
||||
* one rather than silently issuing nothing.
|
||||
*/
|
||||
private Map<String, Object> findReplacementLeaflet(final Long spoiledLeafletId) {
|
||||
// The next number after the spoiled one, so the pad is consumed in the order it was
|
||||
// printed. Falls back to the lowest free number in the pad when the spoiled draft was the
|
||||
// last one, rather than skipping the series entirely.
|
||||
final List<Map<String, Object>> afterSpoiled = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "AND l.dd_number ~ '^[0-9]+$' "
|
||||
+ "AND cast(l.dd_number as bigint) > "
|
||||
+ " (SELECT case when s.dd_number ~ '^[0-9]+$' then cast(s.dd_number as bigint) else -1 end "
|
||||
+ " FROM m_sponsor_dd_leaflet s WHERE s.id = ?) "
|
||||
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId, spoiledLeafletId);
|
||||
if (!afterSpoiled.isEmpty()) {
|
||||
return afterSpoiled.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> sameInventory = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
if (!sameInventory.isEmpty()) {
|
||||
return sameInventory.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> sameBank = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||
+ "WHERE i.sponsor_bank_id = (SELECT i2.sponsor_bank_id FROM m_sponsor_dd_inventory i2 "
|
||||
+ " JOIN m_sponsor_dd_leaflet l2 ON l2.inventory_id = i2.id WHERE l2.id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
if (!sameBank.isEmpty()) {
|
||||
return sameBank.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> anyBank = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||
+ "WHERE l.status = ? AND l.id <> ? ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||
LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
return anyBank.isEmpty() ? null : anyBank.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Demand Draft leaflet not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> findRevokeRequestOrThrow(final Long revokeRequestId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_sponsor_dd_revoke_request WHERE id = ?", revokeRequestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Sponsor DD revoke request not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private boolean isCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
}
|
||||
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (!isCheckerRole(user)) {
|
||||
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject revoke requests.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsInterestReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/tds/deposit-interest")
|
||||
@Component
|
||||
@Tag(name = "TDS on deposit interest",
|
||||
description = "Interest credited on deposits in a financial year, per customer, for the section 194A deduction.")
|
||||
@RequiredArgsConstructor
|
||||
public class TdsInterestApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final TdsInterestReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<TdsDeducteeData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
/**
|
||||
* The financial year the books are currently in, off the business date rather than the wall
|
||||
* clock, so a back-dated working day reports the year that day belongs to.
|
||||
*/
|
||||
private int currentFinancialYear() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
return businessDate.getMonthValue() >= 4 ? businessDate.getYear() : businessDate.getYear() - 1;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
* their own {@code depositTypeCode} - the caller keeps them out of the aggregate and shows them
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account and the interest posted to it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
|
||||
public interface TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Deposit interest credited in a financial year, per customer.
|
||||
*
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
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.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
*
|
||||
* <p>
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
*
|
||||
* <p>
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
*
|
||||
* <p>
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
o.name AS branch_name,
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
}
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
d.form15Type, accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
return value == null ? null : value.toLocalDate();
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable accumulators, private to the grouping above. */
|
||||
private static final class Deductee {
|
||||
|
||||
private final Long clientId;
|
||||
private final String customerId;
|
||||
private final String name;
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Account {
|
||||
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -612,4 +612,7 @@
|
||||
<include file="parts/0534_enable_business_date_advance_jobs.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0535_sync_business_date_to_system_date_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0538_separate_kyc_and_aml_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0539_map_profit_financial_activities.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0544_dividend_round_off_financial_activity.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -69,4 +69,20 @@
|
||||
<include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0541_create_tenant_menu_access.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0542_split_loan_origination_investment_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0543_remove_unused_roles_and_merge_manager_roles.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0544_catchup_teller_is_deleted.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0545_catchup_current_account_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0546_rename_cashier_role_to_title_case.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0547_revoke_all_functions_from_clerk.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0548_drop_unused_facility_charge_income_accounts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0549_group_orphan_gl_accounts_under_headers.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0550_uppercase_gl_account_names.xml" relativeToChangelogFile="true"/>
|
||||
<!-- Numeric prefixes 0546-0548 above collide with the ones below — both sets of migrations
|
||||
were authored in parallel on separate branches. Liquibase identifies a changeset by
|
||||
id+author+filename, not by the numeric prefix, so the overlap is cosmetic only; renaming
|
||||
either set would change that identity and make an already-applied changeset look new to
|
||||
any tenant that ran it under its current filename, so the numbers are left as they are. -->
|
||||
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0548_sponsor_dd_revoke_request.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>
|
||||
|
||||
|
||||
+25
-7
@@ -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">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
<and>
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
<!--
|
||||
0517 creates this same constraint, and it is included ahead of this file in
|
||||
changelog-tenant.xml, so on a brand-new tenant the constraint is already there
|
||||
by the time this runs and adding it again aborts startup. Guarded the way 0517
|
||||
guards its own creation of it.
|
||||
-->
|
||||
<not><indexExists indexName="uq_savings_daily_interest_account"/></not>
|
||||
</and>
|
||||
</preConditions>
|
||||
<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">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
<and>
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
<!-- Same story as changeSet 3: 0517 already creates this index on a new tenant. -->
|
||||
<not><indexExists indexName="idx_sdi_product"/></not>
|
||||
</and>
|
||||
</preConditions>
|
||||
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
|
||||
<column name="savings_product_id"/>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?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.
|
||||
-->
|
||||
<!--
|
||||
Brings the last two accounts of the dividend lifecycle under Financial Activity Mappings.
|
||||
|
||||
The dividend cycle reads its GL accounts from acc_gl_financial_activity_account, except for the two
|
||||
profit accounts, which five classes found by hardcoded GL code:
|
||||
|
||||
BANKPROFITND01 the year's closed-out profit a dividend is declared against
|
||||
BANKPROFIT01 where profit settles once it is no longer distributable - the remainder after an
|
||||
approved declaration, the rounding residue, and dividends forfeited after the
|
||||
three-year claim window
|
||||
|
||||
A society whose chart of accounts did not happen to use those exact codes got no dividend journal
|
||||
entries at all, and nothing reported it: every call site checked for null and returned quietly.
|
||||
They are now FinancialActivity DISTRIBUTABLE_PROFIT (302) and RETAINED_PROFIT (303), configurable
|
||||
like every other account in the cycle, and resolved through DividendGlAccountResolver - which
|
||||
throws rather than posting nothing when a mapping is absent.
|
||||
|
||||
This changeset maps them to whatever those two GL codes resolve to today, so an existing society
|
||||
keeps the exact accounts it has been posting to and needs to do nothing. Where a code does not
|
||||
exist, no row is written: there is no correct account to guess at, and the resolver will name the
|
||||
activity to configure the first time the cycle runs.
|
||||
-->
|
||||
<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.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0539-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0539-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- 'Self Service User' was audited across every tenant and found with zero users assigned
|
||||
anywhere and no wired-up purpose. Principal Officer is kept - it is the statutory
|
||||
PMLA/RBI compliance seat added in 0435, just not yet assigned to anyone. -->
|
||||
<changeSet id="0543-1" author="cbs-nitesh">
|
||||
<sql>
|
||||
DELETE FROM m_role_permission
|
||||
WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User')
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_appuser_role
|
||||
WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User')
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_role WHERE name = 'Self Service User'
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- 'As Manager' and 'Branch Manager' covered overlapping ground (loan/cash operations vs.
|
||||
portfolio/savings operations). Merging them into one surviving role - 'Branch Manager' -
|
||||
rather than just keeping both in sync: Branch Manager first picks up any permission only
|
||||
As Manager had, then As Manager's users move onto Branch Manager, then As Manager itself
|
||||
is removed. -->
|
||||
<changeSet id="0543-2" author="cbs-nitesh">
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT (SELECT id FROM m_role WHERE name = 'Branch Manager'), rp.permission_id
|
||||
FROM m_role_permission rp
|
||||
WHERE rp.role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission existing
|
||||
WHERE existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager')
|
||||
AND existing.permission_id = rp.permission_id)
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_appuser_role (appuser_id, role_id)
|
||||
SELECT ur.appuser_id, (SELECT id FROM m_role WHERE name = 'Branch Manager')
|
||||
FROM m_appuser_role ur
|
||||
WHERE ur.role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_appuser_role existing
|
||||
WHERE existing.appuser_id = ur.appuser_id
|
||||
AND existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager'))
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_appuser_role WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_role_permission WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager')
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_role WHERE name = 'As Manager'
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+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>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?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.
|
||||
-->
|
||||
<!--
|
||||
Gives the paise a dividend cannot divide evenly an account of their own.
|
||||
|
||||
A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 belonging to nobody. That residue was swept
|
||||
into Bank Profit alongside the undistributed remainder, which put an arithmetic artefact inside a figure the society
|
||||
reads as a real business result - and left an auditor asking why members received less than was declared with no
|
||||
single balance to point at.
|
||||
|
||||
Seeded and mapped here so the separation is in place from the first declaration after this runs, and editable from
|
||||
Accounting > Financial Activity Mappings afterwards like every other dividend account. A society that would rather
|
||||
not separate it can delete the mapping: the residue then sweeps to retained profit exactly as it did before.
|
||||
-->
|
||||
<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.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0544-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01'</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
Equity, matching the profit accounts it sits beside: the residue is the society's own money, retained
|
||||
rather than earned, so recognising it as income would overstate the year's result by the rounding.
|
||||
-->
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Dividend Round Off"/>
|
||||
<column name="gl_code" value="DIVROUNDOFF01"/>
|
||||
<column name="disabled" valueBoolean="false"/>
|
||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="3"/>
|
||||
<column name="description" value="Fractions left over when a declared dividend cannot be divided evenly across members' shares. Swept here when the declaration is approved, so the rounding stays readable apart from the society's distributable and retained profit."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0544-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 304</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 304, id FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01';
|
||||
</sql>
|
||||
</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>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?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.
|
||||
-->
|
||||
<!--
|
||||
Makes sure the two profit accounts the dividend cycle runs on actually exist and are mapped.
|
||||
|
||||
0539 mapped them with "SELECT <activity>, id FROM acc_gl_account WHERE gl_code = ...", which inserts nothing at all
|
||||
when the account is absent - no row, no error. BANKPROFITND01 is seeded by 0490, but BANKPROFIT01 never was: it was
|
||||
assumed to be part of every tenant's chart already. A tenant without it got no mapping for RETAINED_PROFIT, and the
|
||||
omission only surfaced as a missing row on the Financial Activity Mappings screen.
|
||||
|
||||
Seeding the account first and mapping second removes that dependency on what a tenant happens to have. Both accounts
|
||||
stay editable afterwards - a society that keeps its profit elsewhere just repoints the mapping, and this file's
|
||||
preconditions leave any existing mapping untouched.
|
||||
-->
|
||||
<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.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0545-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<!-- Matched on name as well as code: 0503 renumbered accounts by name, so a chart where it ran holds
|
||||
this account under a numeric code, and checking the code alone would create a duplicate. -->
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Bank Profit"/>
|
||||
<column name="gl_code" value="BANKPROFIT01"/>
|
||||
<column name="disabled" valueBoolean="false"/>
|
||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="3"/>
|
||||
<column name="description" value="Profit that has already been through a dividend decision: the undistributed remainder moved here when a declaration was approved, and dividends forfeited after the three-year claim window. Never enters a later dividend calculation."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
0545-2 and 0545-3 below are left exactly as they first ran. They matched on gl_code and inserted nothing,
|
||||
because 0503 had already renumbered one of the accounts - see 0545-4. Editing them in place would change their
|
||||
checksum and Liquibase refuses to re-validate a changeset that has already been applied, so the corrected
|
||||
lookup is a new pair of changesets rather than a rewrite of these.
|
||||
-->
|
||||
|
||||
<!-- Distributable Profit (302) - what a dividend is declared against. -->
|
||||
<changeSet author="fineract" id="0545-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Retained Profit (303) - where profit settles once it is no longer distributable. -->
|
||||
<changeSet author="fineract" id="0545-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The same two mappings, looked up by name.
|
||||
|
||||
Every attempt so far searched for gl_code 'BANKPROFITND01' and found nothing, because 0503 standardised the
|
||||
chart onto numeric codes with "UPDATE acc_gl_account SET gl_code = '300903' WHERE name = 'Bank Profit
|
||||
(Non-Distributable)'". The account is still there under code 300903; only the code it was being looked up by
|
||||
had gone. 'BANKPROFIT01' was not part of that rename and still carries its original code, which is why the two
|
||||
sit on inconsistent codes today.
|
||||
|
||||
The name survived the renumbering, so that is what these match on, with gl_code kept as an alternative for a
|
||||
chart where 0503 never ran. Both remain no-ops where a mapping already exists, so a society that has already
|
||||
pointed either activity at its own account keeps that choice.
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0545-4">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account
|
||||
WHERE name = 'Bank Profit (Non-Distributable)' OR gl_code = 'BANKPROFITND01'
|
||||
ORDER BY id LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0545-5">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account
|
||||
WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01'
|
||||
ORDER BY id LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?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">
|
||||
|
||||
<!-- An AML rule could only say "what amount"; it could not say which way the money moved, or
|
||||
whether it had to be cash, or how many transactions make a pattern. Every active rule was
|
||||
therefore matched on amount alone, and one ₹12,00,000 cash deposit raised ten alerts: the
|
||||
genuine CTR hit, plus "frequent low cash WITHDRAWAL", "structuring BELOW ₹50,000",
|
||||
"charitable organisation", "PEP account" and five more that had nothing to do with it.
|
||||
|
||||
An analyst who cannot tell the one real alert from the nine noise ones stops reading them,
|
||||
and that is how a real suspicious transaction gets missed. These three columns let a rule
|
||||
describe the transaction it is actually about. -->
|
||||
|
||||
<changeSet id="0546-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="direction"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- CREDIT / DEBIT, or NULL for a rule that applies either way. -->
|
||||
<column name="direction" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="cash_only"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- A "cash transaction report" rule must not fire on a transfer or a cheque. -->
|
||||
<column name="cash_only" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="min_transaction_count"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- Structuring is many small transactions, not one large one. Without a floor on the
|
||||
count, a single deposit whose amount happens to clear the window threshold reads as
|
||||
a structuring pattern. -->
|
||||
<column name="min_transaction_count" type="INT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-4" author="CBS">
|
||||
<comment>Describe each seeded rule properly, now that a rule can say more than an amount.</comment>
|
||||
<sql>
|
||||
<!-- Cash reporting rules are about cash, in either direction. -->
|
||||
UPDATE m_aml_transaction_rule SET cash_only = true
|
||||
WHERE rule_code IN ('CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L',
|
||||
'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K', 'FREQUENT_CASH_NEAR_10L',
|
||||
'FREQUENT_LOW_CASH_DEPOSIT', 'FREQUENT_LOW_CASH_WITHDRAWAL',
|
||||
'CHARITABLE_ORG_CASH_WITHDRAWAL', 'LOAN_REPAYMENT_IN_CASH');
|
||||
|
||||
<!-- Rules whose own name says which way the money went. -->
|
||||
UPDATE m_aml_transaction_rule SET direction = 'CREDIT'
|
||||
WHERE rule_code IN ('FREQUENT_LOW_CASH_DEPOSIT', 'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K',
|
||||
'FREQUENT_CASH_NEAR_10L');
|
||||
UPDATE m_aml_transaction_rule SET direction = 'DEBIT'
|
||||
WHERE rule_code IN ('FREQUENT_LOW_CASH_WITHDRAWAL', 'CHARITABLE_ORG_CASH_WITHDRAWAL',
|
||||
'LOAN_REPAYMENT_IN_CASH');
|
||||
|
||||
<!-- Three or more in the window before a pattern is called structuring: two large
|
||||
deposits in a month is ordinary business, and RBI's own wording is "numerous". -->
|
||||
UPDATE m_aml_transaction_rule SET min_transaction_count = 3
|
||||
WHERE rule_type IN ('STRUCTURING', 'VELOCITY');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Every other role reads in title case ('Clerk', 'Teller', 'Branch Manager'); 'CASHIER' was
|
||||
the one seeded in all caps, which is what showed up as an all-caps chip in the user-role
|
||||
picker. Role name is only ever matched case-insensitively (CashierRoleHelper.isCashierOnly,
|
||||
validateCheckerRole), so this rename changes nothing about who counts as a cashier. -->
|
||||
<changeSet id="0546-1" author="cbs-nitesh">
|
||||
<sql>
|
||||
UPDATE m_role SET name = 'Cashier' WHERE name = 'CASHIER'
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?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">
|
||||
|
||||
<!-- Direction, cash and count (0541) described the TRANSACTION. Some rules are instead about
|
||||
WHO is transacting: "PEP account monitoring" is about politically exposed persons,
|
||||
"charitable organisation cash withdrawal" about trusts and societies, "jeweller account"
|
||||
about dealers in precious metal. None of that was checked, so each of them read as a plain
|
||||
amount rule and fired on every ordinary customer who crossed ₹50,000 - PEP_ACCOUNT_MONITORING
|
||||
was raised against an individual salaried customer for exactly this reason.
|
||||
|
||||
An entity rule that cannot identify its entity must raise nothing. Reporting every customer
|
||||
as a PEP is not the safe direction: it buries the real ones. -->
|
||||
|
||||
<changeSet id="0547-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="subject_scope"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- PEP / CHARITABLE_ORG / HIGH_RISK_BUSINESS / NEW_ACCOUNT, or NULL for a rule that is
|
||||
about the transaction rather than the customer behind it. -->
|
||||
<column name="subject_scope" type="VARCHAR(30)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0547-2" author="CBS">
|
||||
<comment>Scope the four entity rules to the customers they are actually written about.</comment>
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'PEP'
|
||||
WHERE rule_code = 'PEP_ACCOUNT_MONITORING';
|
||||
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'CHARITABLE_ORG'
|
||||
WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL';
|
||||
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'HIGH_RISK_BUSINESS'
|
||||
WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN';
|
||||
|
||||
<!-- "Large transaction on a NEW account" - the age of the account is the whole point of
|
||||
the rule, and it was the one thing not being looked at. -->
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'NEW_ACCOUNT'
|
||||
WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Clerk somehow carried ALL_FUNCTIONS, which bypasses every individual permission check
|
||||
(HasPermissionDirective, and this table's own role-permission rows) unconditionally - a
|
||||
Clerk should only ever have the specific request/submit rights this role's own explicit
|
||||
permission rows already grant, never full access. Revoking just that one row; Clerk's
|
||||
real, explicit permission set is untouched. -->
|
||||
<changeSet id="0547-1" author="cbs-nitesh">
|
||||
<sql>
|
||||
DELETE FROM m_role_permission
|
||||
WHERE role_id = (SELECT id FROM m_role WHERE name = 'Clerk')
|
||||
AND permission_id = (SELECT id FROM m_permission WHERE code = 'ALL_FUNCTIONS')
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+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>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?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 sponsor bank sends a limited pad of blank drafts, and a leaflet spoiled in the printer is
|
||||
gone: the paper cannot be re-used, and the number printed on it cannot be given to anyone
|
||||
else. The branch has to hand the customer the next blank instead.
|
||||
|
||||
Reprint does not cover this. Reprint puts the same draft back through the printer on the
|
||||
same leaflet - right when the print jammed but the paper survived. Here the paper itself is
|
||||
spoiled, so a different leaflet has to carry the draft, and the register must show which
|
||||
one replaced which or the stock will not reconcile against the sponsor bank's pad. -->
|
||||
|
||||
<changeSet id="0548-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_sponsor_dd_revoke_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_sponsor_dd_revoke_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- The spoiled leaflet. -->
|
||||
<column name="leaflet_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- The blank that carried the draft instead. Filled on approval, because until a
|
||||
checker agrees no stock has moved and nothing has been consumed. -->
|
||||
<column name="replacement_leaflet_id" type="BIGINT"/>
|
||||
<column name="reason" type="VARCHAR(500)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="maker_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_date" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_dd_revoke_leaflet"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_sponsor_dd_revoke_request" baseColumnNames="leaflet_id"
|
||||
constraintName="fk_dd_revoke_leaflet" referencedTableName="m_sponsor_dd_leaflet"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_dd_revoke_replacement"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_sponsor_dd_revoke_request" baseColumnNames="replacement_leaflet_id"
|
||||
constraintName="fk_dd_revoke_replacement" referencedTableName="m_sponsor_dd_leaflet"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-4" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_dd_revoke_status"/></not>
|
||||
</preConditions>
|
||||
<comment>The approval queue reads by status; the register reads by leaflet.</comment>
|
||||
<createIndex tableName="m_sponsor_dd_revoke_request" indexName="idx_dd_revoke_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-5" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_dd_revoke_leaflet"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_sponsor_dd_revoke_request" indexName="idx_dd_revoke_leaflet">
|
||||
<column name="leaflet_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-6" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_sponsor_dd_leaflet" columnName="revoked_replaced_by_leaflet_id"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
Carried on the leaflet as well as on the request, so the register can show "replaced by
|
||||
DD 000002" without joining through a request row that a purge might one day remove.
|
||||
</comment>
|
||||
<addColumn tableName="m_sponsor_dd_leaflet">
|
||||
<column name="revoked_replaced_by_leaflet_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-7" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_sponsor_dd_leaflet" columnName="revoked_replacement_of_leaflet_id"/></not>
|
||||
</preConditions>
|
||||
<comment>The other direction: a replacement says which spoiled leaflet it stands in for.</comment>
|
||||
<addColumn tableName="m_sponsor_dd_leaflet">
|
||||
<column name="revoked_replacement_of_leaflet_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</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