feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module

This commit is contained in:
2026-09-14 11:46:16 +05:30
parent 3d196276c8
commit 1c6ac9d77f
42 changed files with 2319 additions and 185 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)) {
@@ -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,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;
}
}
@@ -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,7 @@ 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.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -22,7 +23,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 +43,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 +74,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 +167,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 +285,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 +344,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);
}
@@ -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);
}
}
@@ -45,6 +45,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -94,12 +95,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 +492,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;
@@ -22,6 +22,7 @@ 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.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -39,11 +40,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 +49,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 +127,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();
@@ -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,7 @@ import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
import org.apache.fineract.commands.domain.CommandProcessingResultType;
import org.apache.fineract.commands.domain.CommandSource;
@@ -88,10 +89,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 +97,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 +294,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 +347,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 +398,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 +492,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 +524,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 +540,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 +560,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 +582,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.
@@ -745,11 +696,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 +717,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;
}
}
}
@@ -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>
@@ -67,4 +67,7 @@
<include file="parts/0538_add_branch_pincode.xml" relativeToChangelogFile="true"/>
<include file="parts/0539_seed_cash_exchange_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/>
<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>
@@ -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,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,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,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,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>