Compare commits

..
6 Commits
Author SHA1 Message Date
nitesh af43944699 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-14 12:17:01 +05:30
keshav 39fa100c70 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-14 11:46:43 +05:30
keshav 1c6ac9d77f feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module 2026-09-14 11:46:16 +05:30
niteshandClaude Sonnet 5 6bb512e1fe fix(cash): recognize GL-entry cash correctly in reconciliation and reports
- Add CountedCashLedger: one shared rule for which m_savings_txn_denomination
  rows are real counted cash. Replaces a magnitude-only guard (which had
  itself been silently excluding legitimate manual-GL-entry cash from every
  cash screen) with a date-plausibility test -- a stamped garbage id decodes
  back to the day it was written, a real GL reference to an arbitrary date --
  so a hex reference that happens to land in the old guard's excluded band is
  no longer misclassified as timestamp garbage
- Apply it consistently in the day register close preview, the cashier
  position/day-book reports, the cash reconciliation screen, and the
  withdrawal stock check, so all four agree on the same drawer
- GL Entries report: join m_voucher_line to show both legs of each voucher
  (debit and credit account) instead of only the cash leg its own
  denomination row happens to carry

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

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

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

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

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