feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module
This commit is contained in:
+20
-9
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
|
||||
*/
|
||||
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
||||
|
||||
/**
|
||||
* Activities an administrator may map from the Financial Activity Mappings screen.
|
||||
*
|
||||
* Held in one place because create and update both validate against it, and the two lists had already drifted -
|
||||
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
|
||||
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
|
||||
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
|
||||
*/
|
||||
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
|
||||
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
|
||||
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
|
||||
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
|
||||
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
|
||||
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
|
||||
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
|
||||
|
||||
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
||||
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||
|
||||
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||
element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||
|
||||
+26
-1
@@ -451,7 +451,32 @@ public final class AccountingConstants {
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||
/**
|
||||
* The year's closed-out profit a dividend is declared against.
|
||||
*
|
||||
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||
* distribution a second time.
|
||||
*/
|
||||
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||
* calculation.
|
||||
*/
|
||||
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where the paise a dividend cannot divide evenly are collected.
|
||||
*
|
||||
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||
*
|
||||
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||
*/
|
||||
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+43
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||
* A rule named for withdrawals must not fire on a deposit.
|
||||
*/
|
||||
@Column(name = "direction", length = 10)
|
||||
private String direction;
|
||||
|
||||
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||
@Column(name = "cash_only", nullable = false)
|
||||
private boolean cashOnly;
|
||||
|
||||
/**
|
||||
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||
* many small transactions; without this floor a single large one satisfies the window total and
|
||||
* reads as a pattern it is not.
|
||||
*/
|
||||
@Column(name = "min_transaction_count")
|
||||
private Integer minTransactionCount;
|
||||
|
||||
/**
|
||||
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||
* about the transaction alone and so applies to everyone.
|
||||
*/
|
||||
@Column(name = "subject_scope", length = 30)
|
||||
private String subjectScope;
|
||||
|
||||
public String getSubjectScope() {
|
||||
return subjectScope;
|
||||
}
|
||||
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public boolean isCashOnly() {
|
||||
return cashOnly;
|
||||
}
|
||||
|
||||
public Integer getMinTransactionCount() {
|
||||
return minTransactionCount;
|
||||
}
|
||||
|
||||
protected AmlTransactionRule() {}
|
||||
|
||||
/**
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.common;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
|
||||
* configures.
|
||||
*
|
||||
* <p>
|
||||
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
|
||||
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
|
||||
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
|
||||
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
|
||||
* screen.
|
||||
*
|
||||
* <p>
|
||||
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
|
||||
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
|
||||
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
|
||||
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DividendGlAccountResolver {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The year's closed-out profit a dividend is declared against. */
|
||||
public Long distributableProfitAccount() {
|
||||
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
|
||||
}
|
||||
|
||||
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
|
||||
public Long retainedProfitAccount() {
|
||||
return requireMapped(FinancialActivity.RETAINED_PROFIT);
|
||||
}
|
||||
|
||||
/** Equity the declared profit is set aside in, between declaration and board approval. */
|
||||
public Long reserveForDividendAccount() {
|
||||
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
|
||||
}
|
||||
|
||||
/** The liability to members, created on approval and discharged as each member claims. */
|
||||
public Long payableDividendsAccount() {
|
||||
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the paise a dividend cannot divide evenly are swept.
|
||||
*
|
||||
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
|
||||
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
|
||||
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
|
||||
* a decision the society has failed to make, which is why it resolves rather than throwing.
|
||||
*/
|
||||
public Long dividendRoundOffAccount() {
|
||||
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
|
||||
return roundOff != null ? roundOff : retainedProfitAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
|
||||
*
|
||||
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
|
||||
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
|
||||
* returns null instead of throwing.
|
||||
*/
|
||||
public Long dividendPaidAccountOrNull() {
|
||||
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
|
||||
}
|
||||
|
||||
/** The GL mapped to an activity, or null when the society has not configured it. */
|
||||
public Long mappedAccount(final FinancialActivity activity) {
|
||||
return this.jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
activity.getValue())
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private Long requireMapped(final FinancialActivity activity) {
|
||||
final Long glAccountId = mappedAccount(activity);
|
||||
if (glAccountId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
|
||||
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
|
||||
+ "Mappings before running the dividend cycle.",
|
||||
activity.getValue());
|
||||
}
|
||||
return glAccountId;
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -37,6 +37,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -60,13 +61,12 @@ import org.springframework.stereotype.Component;
|
||||
public class DistributableProfitApiResource {
|
||||
|
||||
/**
|
||||
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
|
||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
||||
* The year's closed-out profit, which is what a dividend may be declared against.
|
||||
*
|
||||
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* offer profit already distributed once for distribution again.
|
||||
*/
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||
|
||||
@@ -162,9 +162,9 @@ public class DistributableProfitApiResource {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
|
||||
java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource {
|
||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: incomeGlCodeParam.trim();
|
||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
|
||||
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
|
||||
|
||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||
: financialYearParam.trim();
|
||||
|
||||
+32
-15
@@ -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);
|
||||
}
|
||||
|
||||
+14
@@ -25,6 +25,7 @@ import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
@@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -47,6 +49,7 @@ public class AmlCtrReportApiResource {
|
||||
private final AmlCtrReportReadPlatformService readPlatformService;
|
||||
private final AmlCtrReportGenerationService generationService;
|
||||
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
||||
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -58,6 +61,17 @@ public class AmlCtrReportApiResource {
|
||||
return this.toApiJsonSerializer.serialize(reports);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("lines")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "The reportable lines behind the generated CTRs",
|
||||
description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.")
|
||||
public String retrieveLines(@QueryParam("reportId") final Long reportId) {
|
||||
this.context.authenticatedUser();
|
||||
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("generate")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather
|
||||
* than only the ids the line is stored under.
|
||||
*
|
||||
* <p>
|
||||
* The stored line keeps a client id and a transaction id; a filing needs the account, the customer,
|
||||
* the branch and the date those ids stand for. They are joined here so the screen and the CSV are
|
||||
* built from the report the bank generated, not from a second calculation that could disagree with
|
||||
* it.
|
||||
*
|
||||
* <p>
|
||||
* A connected-group line has no single transaction behind it - it is a month's worth of smaller
|
||||
* cash movements by one customer that together cross the threshold - so its transaction id, account
|
||||
* and date are null, and {@code connectedGroup} says why.
|
||||
*/
|
||||
public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId,
|
||||
Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction,
|
||||
BigDecimal amount, boolean connectedGroup,
|
||||
/**
|
||||
* Account facts the Account_Detail file reports. They were being filled in with a constant
|
||||
* opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement
|
||||
* about the customer's account that nobody had checked.
|
||||
*/
|
||||
LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus,
|
||||
/** Customer identifiers the Account_Person_Relation file reports. */
|
||||
String ckycNumber, String clientExternalId, boolean individual) implements Serializable {}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
/**
|
||||
* Which way the money moved, for rules written about one direction only.
|
||||
*
|
||||
* <p>
|
||||
* Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised
|
||||
* "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one
|
||||
* transaction were of that kind, and an analyst reading them cannot tell which one is real.
|
||||
*/
|
||||
public enum AmlDirection {
|
||||
|
||||
/** Money into the customer's account: a deposit, or a loan disbursal. */
|
||||
CREDIT,
|
||||
/** Money out of it: a withdrawal, or a loan repayment. */
|
||||
DEBIT,
|
||||
/** Not established — the rule's own direction filter, if it has one, then does not apply. */
|
||||
UNKNOWN;
|
||||
|
||||
public boolean isCredit() {
|
||||
return this == CREDIT;
|
||||
}
|
||||
|
||||
public boolean isDebit() {
|
||||
return this == DEBIT;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
/**
|
||||
* The kind of customer a rule is written about, for rules whose subject is the customer rather than
|
||||
* the transaction.
|
||||
*
|
||||
* <p>
|
||||
* Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity
|
||||
* in their own title, but nothing checked it: each was matched on amount alone and so fired on every
|
||||
* ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious
|
||||
* reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts,
|
||||
* and the one real politically exposed person is then missed among them.
|
||||
*/
|
||||
public enum AmlSubjectScope {
|
||||
|
||||
/** Politically exposed person, their relative or close associate. */
|
||||
PEP,
|
||||
|
||||
/** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */
|
||||
CHARITABLE_ORG,
|
||||
|
||||
/**
|
||||
* Dealers in precious metal and stones and the other businesses RBI names as higher risk,
|
||||
* where a large transaction is worth reporting that would be routine for anyone else.
|
||||
*/
|
||||
HIGH_RISK_BUSINESS,
|
||||
|
||||
/**
|
||||
* A recently opened account. A large transaction through an account that has barely been used
|
||||
* is the classic mule pattern; the same transaction on a decade-old account is not remarkable.
|
||||
*/
|
||||
NEW_ACCOUNT;
|
||||
|
||||
public static AmlSubjectScope fromRule(final String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return AmlSubjectScope.valueOf(value.trim().toUpperCase());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
// An unrecognised scope must not silently widen the rule back to everyone, so the
|
||||
// caller treats null-from-blank and this case differently.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -19,7 +19,9 @@
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
@@ -45,9 +47,43 @@ public final class AmlTransactionContext {
|
||||
* rules are simply skipped for this context rather than guessed at.
|
||||
*/
|
||||
private final BigDecimal windowAggregateAmount;
|
||||
/**
|
||||
* Which way the money moved. A rule written about withdrawals must not fire on a deposit, and
|
||||
* "repayment of loan in cash" is not the same event as a disbursal.
|
||||
*/
|
||||
private final AmlDirection direction;
|
||||
/**
|
||||
* How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a
|
||||
* pattern of MANY small transactions, so the count is what separates it from one large one:
|
||||
* a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads.
|
||||
*/
|
||||
private final int windowTransactionCount;
|
||||
/**
|
||||
* Every qualifying amount in the window, so a rule can count only what falls inside its own
|
||||
* band. A single count cannot serve every rule: this client's window held four deposits — three
|
||||
* small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule
|
||||
* whose band none of the four sat in. Structuring is several transactions of a similar size,
|
||||
* and only the amounts themselves can say whether that happened.
|
||||
*/
|
||||
private final List<BigDecimal> windowAmounts;
|
||||
|
||||
/**
|
||||
* When the account this transaction moved through was opened. The "large transaction on a new
|
||||
* account" rule is about the account rather than the customer, and an old customer can open a
|
||||
* new account. Null where the caller could not establish it, in which case the customer's own
|
||||
* activation date stands in.
|
||||
*/
|
||||
private final LocalDate subjectOpenedOn;
|
||||
|
||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
||||
this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN,
|
||||
0, List.of(), null);
|
||||
}
|
||||
|
||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction,
|
||||
int windowTransactionCount, List<BigDecimal> windowAmounts, LocalDate subjectOpenedOn) {
|
||||
this.transactionType = transactionType;
|
||||
this.transactionId = transactionId;
|
||||
this.client = client;
|
||||
@@ -56,6 +92,26 @@ public final class AmlTransactionContext {
|
||||
this.occurredOn = occurredOn;
|
||||
this.cash = cash;
|
||||
this.windowAggregateAmount = windowAggregateAmount;
|
||||
this.direction = direction;
|
||||
this.windowTransactionCount = windowTransactionCount;
|
||||
this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts);
|
||||
this.subjectOpenedOn = subjectOpenedOn;
|
||||
}
|
||||
|
||||
public AmlDirection getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public int getWindowTransactionCount() {
|
||||
return windowTransactionCount;
|
||||
}
|
||||
|
||||
public List<BigDecimal> getWindowAmounts() {
|
||||
return windowAmounts;
|
||||
}
|
||||
|
||||
public LocalDate getSubjectOpenedOn() {
|
||||
return subjectOpenedOn;
|
||||
}
|
||||
|
||||
public AmlTransactionType getTransactionType() {
|
||||
|
||||
+23
-6
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
|
||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
||||
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||
@@ -113,12 +114,22 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
||||
}
|
||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
||||
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
|
||||
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
// Same side of the ledger as the transaction being observed: a deposit is not part of a
|
||||
// withdrawal pattern, so the two are aggregated separately.
|
||||
final List<SavingsAccountTransaction> sameDirection = recent.stream()
|
||||
.filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList();
|
||||
// Each amount is carried through, not just the total: a rule has to be able to count what
|
||||
// falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or
|
||||
// more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be
|
||||
// reported as structuring.
|
||||
final List<BigDecimal> windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList();
|
||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
||||
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate,
|
||||
depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts,
|
||||
transaction.getSavingsAccount().getActivationDate());
|
||||
try {
|
||||
ruleEvaluationService.evaluate(context);
|
||||
} catch (final RuntimeException e) {
|
||||
@@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
||||
}
|
||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
||||
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
|
||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
|
||||
final List<BigDecimal> windowAmounts = repayments.stream()
|
||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList();
|
||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// A repayment takes money from the customer; a disbursal gives it to them. Rules such as
|
||||
// "repayment of loan in cash" are about the first and must not fire on the second.
|
||||
final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT;
|
||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
||||
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction,
|
||||
repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate());
|
||||
try {
|
||||
ruleEvaluationService.evaluate(context);
|
||||
} catch (final RuntimeException e) {
|
||||
|
||||
+9
@@ -20,8 +20,17 @@ package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
|
||||
public interface AmlCtrReportReadPlatformService {
|
||||
|
||||
List<AmlCtrReportData> retrieveAll();
|
||||
|
||||
/**
|
||||
* The reportable lines, resolved to the account, customer and branch a filing names.
|
||||
*
|
||||
* @param reportId
|
||||
* one month's report, or null for every generated month
|
||||
*/
|
||||
List<AmlCtrReportLineData> retrieveLines(Long reportId);
|
||||
}
|
||||
|
||||
+59
@@ -18,13 +18,16 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -33,7 +36,40 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional(readOnly = true)
|
||||
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
||||
|
||||
/**
|
||||
* Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash
|
||||
* moved, so the line says so rather than leaving the reader to infer it from the amount.
|
||||
*/
|
||||
private static final String LINES_SQL = """
|
||||
SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id,
|
||||
l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group,
|
||||
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
||||
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
||||
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
||||
sa.activatedon_date AS account_opened_on,
|
||||
sa.closedon_date AS account_closed_on,
|
||||
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
||||
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
||||
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
||||
WHEN 800 THEN 'MATURED' END AS account_status,
|
||||
k.ckyc_number AS ckyc_number,
|
||||
-- The customer's own reference for the filing: CKYC number if the record carries
|
||||
-- one, else the CIF the bank knows them by. Never blank, because a relation row
|
||||
-- that identifies nobody cannot be reconciled against the customer card.
|
||||
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
||||
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
||||
FROM m_aml_ctr_report_line l
|
||||
JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id
|
||||
JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS'
|
||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
""";
|
||||
|
||||
private final AmlCtrReportRepository repository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<AmlCtrReportData> retrieveAll() {
|
||||
@@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
||||
.map(this::mapToData).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AmlCtrReportLineData> retrieveLines(final Long reportId) {
|
||||
final StringBuilder sql = new StringBuilder(LINES_SQL);
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (reportId != null) {
|
||||
sql.append(" WHERE l.ctr_report_id = ? ");
|
||||
args.add(reportId);
|
||||
}
|
||||
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
|
||||
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
|
||||
|
||||
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
|
||||
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
|
||||
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
|
||||
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
|
||||
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||
rs.getBoolean("individual")), args.toArray());
|
||||
}
|
||||
|
||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
||||
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
||||
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
||||
|
||||
+118
-2
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
@@ -54,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
private final AmlSubjectScopeResolver subjectScopeResolver;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -81,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (!appliesToThisTransaction(rule, context)) {
|
||||
return false;
|
||||
}
|
||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||
if (threshold == null) {
|
||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||
@@ -91,14 +96,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
case THRESHOLD:
|
||||
return context.getAmount().compareTo(threshold) >= 0;
|
||||
case STRUCTURING:
|
||||
return isStructuringHit(rule, context, threshold);
|
||||
case VELOCITY:
|
||||
return context.getWindowAggregateAmount() != null
|
||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||
return isVelocityHit(rule, context, threshold);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule is about this transaction at all, before any amount is compared.
|
||||
*
|
||||
* <p>
|
||||
* Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000
|
||||
* cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low
|
||||
* cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about
|
||||
* transactions this was not. An analyst who cannot tell which of ten alerts is real stops
|
||||
* reading them, and that is how a real one gets missed.
|
||||
*/
|
||||
private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
// A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by
|
||||
// definition and is not what CTR exists to catch.
|
||||
if (rule.isCashOnly() && !context.isCash()) {
|
||||
return false;
|
||||
}
|
||||
// Direction, where the rule names one. UNKNOWN means the caller could not establish it, so
|
||||
// the filter is not applied rather than guessed at.
|
||||
final String ruleDirection = rule.getDirection();
|
||||
if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN
|
||||
&& !ruleDirection.equalsIgnoreCase(context.getDirection().name())) {
|
||||
return false;
|
||||
}
|
||||
// And who the rule is about, for rules named after an entity rather than an amount.
|
||||
// PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing
|
||||
// here asked whether that customer was politically exposed.
|
||||
return subjectScopeResolver.matches(rule.getSubjectScope(), context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structuring: many transactions each deliberately kept below a reporting threshold, summing
|
||||
* to something that would have been reportable had it been done at once.
|
||||
*
|
||||
* <p>
|
||||
* Two things separate it from one large transaction, and neither was being checked. The count:
|
||||
* a pattern needs several transactions, so a single deposit is never structuring however large.
|
||||
* And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000
|
||||
* deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what
|
||||
* that rule used to report it as.
|
||||
*/
|
||||
private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||
if (context.getWindowAggregateAmount() == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||
return false;
|
||||
}
|
||||
// Counted and summed over the rule's own band, not over the window as a whole. A window
|
||||
// holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a
|
||||
// ₹10,00,000 total on the raw figures, and that is how one large deposit came to be
|
||||
// reported as structuring: none of the four was in the band the rule describes.
|
||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||
return false;
|
||||
}
|
||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Velocity: an unusual number of transactions in a short window, whatever each one is worth.
|
||||
*
|
||||
* <p>
|
||||
* Like structuring it needs a count — one transaction is not a velocity — but unlike structuring
|
||||
* the individual amounts are not confined to a band, since the concern is the frequency itself.
|
||||
*/
|
||||
private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||
if (context.getWindowAggregateAmount() == null) {
|
||||
return false;
|
||||
}
|
||||
// Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run
|
||||
// of small ones, and a single ₹12,00,000 is not an instance of it however often the account
|
||||
// is used.
|
||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||
return false;
|
||||
}
|
||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||
return false;
|
||||
}
|
||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this transaction sits inside the band the rule describes.
|
||||
*
|
||||
* <p>
|
||||
* A rule with no band stated matches any amount, which is how these rules behaved before the
|
||||
* columns existed and is what a rule that only names a window total still means.
|
||||
*/
|
||||
private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) {
|
||||
final BigDecimal min = rule.getMinLimitAmount();
|
||||
final BigDecimal max = rule.getMaxLimitAmount();
|
||||
if (min != null && amount.compareTo(min) < 0) {
|
||||
return false;
|
||||
}
|
||||
return max == null || amount.compareTo(max) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether enough transactions have happened in the window to call this a pattern.
|
||||
*
|
||||
* <p>
|
||||
* A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3,
|
||||
* because two large deposits in a month is ordinary business and RBI's own wording is
|
||||
* "numerous".
|
||||
*/
|
||||
private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) {
|
||||
final Integer minimum = rule.getMinTransactionCount();
|
||||
return minimum == null || minimum <= 1 || countInBand >= minimum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
|
||||
* written about.
|
||||
*
|
||||
* <p>
|
||||
* Everything here is read from what the customer record already holds - the client type chosen at
|
||||
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
|
||||
* because the amount is what the rule's threshold is already for.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AmlSubjectScopeResolver {
|
||||
|
||||
/**
|
||||
* Client types that make an account a charitable/not-for-profit one for the purposes of the
|
||||
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
|
||||
* per-tenant and these are seeded per tenant.
|
||||
*/
|
||||
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
|
||||
|
||||
/**
|
||||
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
|
||||
* worth a second look that the same amount elsewhere would not be.
|
||||
*/
|
||||
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
|
||||
"DEALER IN PRECIOUS METALS", "CASINO");
|
||||
|
||||
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
|
||||
private static final int NEW_ACCOUNT_DAYS = 180;
|
||||
|
||||
private static final int HIGH_RISK_KYC_CATEGORY = 3;
|
||||
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
|
||||
/**
|
||||
* Whether a rule carrying this scope should look at this transaction at all.
|
||||
*
|
||||
* <p>
|
||||
* A rule with no scope is about the transaction rather than the customer and applies to
|
||||
* everyone, which is how these rules behaved before the column existed.
|
||||
*/
|
||||
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
|
||||
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
|
||||
if (scope == null) {
|
||||
return true;
|
||||
}
|
||||
final Client client = context.getClient();
|
||||
if (client == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (scope) {
|
||||
case PEP -> isPoliticallyExposed(client);
|
||||
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
|
||||
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
|
||||
case NEW_ACCOUNT -> isNewAccount(context);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
|
||||
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
|
||||
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
|
||||
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
|
||||
* report everyone.
|
||||
*/
|
||||
private boolean isPoliticallyExposed(final Client client) {
|
||||
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|
||||
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
|
||||
return true;
|
||||
}
|
||||
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
|
||||
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
|
||||
}
|
||||
|
||||
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
|
||||
final String type = client.clientType() == null ? null : client.clientType().getLabel();
|
||||
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
|
||||
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured from the customer's activation date against the business date, not the wall clock,
|
||||
* so a back-dated test and a back-dated correction both read the age the books read.
|
||||
*/
|
||||
private boolean isNewAccount(final AmlTransactionContext context) {
|
||||
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
|
||||
: context.getClient().getActivationDate();
|
||||
if (openedOn == null) {
|
||||
return false;
|
||||
}
|
||||
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final String normalised = value.trim().toUpperCase();
|
||||
return wanted.contains(normalised);
|
||||
}
|
||||
|
||||
private boolean codeValueMatches(final String value, final String token) {
|
||||
return value != null && value.trim().toUpperCase().contains(token);
|
||||
}
|
||||
}
|
||||
+25
-17
@@ -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;
|
||||
|
||||
+4
-15
@@ -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();
|
||||
|
||||
+3
-2
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.shareaccounts.start;
|
||||
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
@@ -115,9 +116,9 @@ public class ShareAccountsConfiguration {
|
||||
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
||||
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
|
||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate, DividendGlAccountResolver dividendGlAccounts) {
|
||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
||||
context, jdbcTemplate);
|
||||
context, jdbcTemplate, dividendGlAccounts);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+16
-6
@@ -39,6 +39,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -77,8 +78,11 @@ public class AllProductsDividendApiResource {
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||
private static final int DIVIDEND_STATUS_INITIATED = 100;
|
||||
|
||||
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
/**
|
||||
* The account a dividend is declared against, once year-end closing has landed the profit in it — configured as
|
||||
* the Distributable Profit financial activity rather than found by a hardcoded GL code.
|
||||
*/
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
/**
|
||||
* A dividend is a rate on each member's own paid-up capital, and nothing else.
|
||||
@@ -187,7 +191,7 @@ public class AllProductsDividendApiResource {
|
||||
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
|
||||
out.put("ratePercentage", ratePercentage);
|
||||
out.put("totalWeightedCapital", totalWeightedCapital);
|
||||
out.put("profitGlCode", PROFIT_GL_CODE);
|
||||
out.put("profitGlCode", distributableProfitGlCode());
|
||||
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
|
||||
// visible on screen before submission — the command layer enforces the same ceiling, and an
|
||||
// officer should not discover it only when the declaration is rejected.
|
||||
@@ -428,9 +432,9 @@ public class AllProductsDividendApiResource {
|
||||
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(fyStart),
|
||||
java.sql.Date.valueOf(fyEnd));
|
||||
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
||||
}
|
||||
}
|
||||
@@ -494,6 +498,12 @@ public class AllProductsDividendApiResource {
|
||||
}
|
||||
|
||||
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
|
||||
/** The configured Distributable Profit account's GL code, for display on the declaration screen. */
|
||||
private String distributableProfitGlCode() {
|
||||
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class,
|
||||
dividendGlAccounts.distributableProfitAccount());
|
||||
}
|
||||
|
||||
private String financialYearOf(final String isoDate) {
|
||||
final LocalDate date = LocalDate.parse(isoDate);
|
||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
|
||||
+36
-96
@@ -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);
|
||||
|
||||
+5
-2
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.shareproducts.starter;
|
||||
|
||||
import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
@@ -117,11 +118,13 @@ public class ShareProductsConfiguration {
|
||||
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
|
||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate,
|
||||
DividendGlAccountResolver dividendGlAccounts) {
|
||||
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
||||
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
||||
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
|
||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate,
|
||||
dividendGlAccounts);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+46
@@ -52,11 +52,13 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDRevokeRequestWritePlatformService;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -72,6 +74,7 @@ public class SponsorDDApiResource {
|
||||
private final SponsorDDReconciliationService reconciliationService;
|
||||
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
||||
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
@@ -426,6 +429,49 @@ public class SponsorDDApiResource {
|
||||
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
||||
}
|
||||
|
||||
// ── Revoking a leaflet spoiled at the printer ──────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("leaflets/{leafletId}/revoke-request")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Request to revoke a spoiled DD and replace it",
|
||||
description = "Clerk (maker) step: the leaflet was ruined at the printer, so its draft must move onto the next available blank. Distinct from a reprint, which re-prints the same leaflet.")
|
||||
public CommandProcessingResult requestRevoke(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.requestRevoke(leafletId, request.getReason()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("revoke-requests")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DD revoke requests",
|
||||
description = "Returns all DD revoke requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.")
|
||||
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(@QueryParam("status") final String status) {
|
||||
return this.readPlatformService.retrieveAllRevokeRequests(status);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("revoke-requests/{revokeRequestId}/approve")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a DD revoke request",
|
||||
description = "Manager/head-cashier (checker) step: marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. No money moves - the customer was already debited and is still owed the same draft.")
|
||||
public CommandProcessingResult approveRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.approveRevokeRequest(revokeRequestId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("revoke-requests/{revokeRequestId}/reject")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reject a DD revoke request",
|
||||
description = "Manager/head-cashier (checker) step: rejects a pending DD revoke request. The leaflet stays issued.")
|
||||
public CommandProcessingResult rejectRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId,
|
||||
final RejectIssueRequestRequest request) {
|
||||
return safely(() -> this.revokeRequestWritePlatformService.rejectRevokeRequest(revokeRequestId, request.getRejectionReason()));
|
||||
}
|
||||
|
||||
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
|
||||
|
||||
@POST
|
||||
|
||||
+16
@@ -59,4 +59,20 @@ public class SponsorDDLeafletData implements Serializable {
|
||||
private String approverUsername;
|
||||
/** Set while a correction to this draft is awaiting a manager. */
|
||||
private Long pendingAmendmentRequestId;
|
||||
|
||||
/** Set while a revoke is awaiting a checker, so the register offers it only once. */
|
||||
private Long pendingRevokeRequestId;
|
||||
|
||||
/**
|
||||
* Where a spoiled leaflet's draft went, and which spoiled leaflet a replacement stands in for.
|
||||
*
|
||||
* <p>
|
||||
* Both directions are shown in the register: a revoked leaflet reads as consumed stock with a
|
||||
* pointer to the draft that survived it, and the replacement says what it was issued against.
|
||||
* Without the pair the pad simply comes up short against the sponsor bank's range.
|
||||
*/
|
||||
private Long revokedReplacedByLeafletId;
|
||||
private String revokedReplacedByDdNumber;
|
||||
private Long revokedReplacementOfLeafletId;
|
||||
private String revokedReplacementOfDdNumber;
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A request to replace a leaflet spoiled at the printer.
|
||||
*
|
||||
* <p>
|
||||
* Carries the draft's own details as well as the request's, because a checker approving this is
|
||||
* deciding to consume a blank from limited stock - they need to see whose draft it is and for how
|
||||
* much without opening the register in another tab.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SponsorDDRevokeRequestData implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private Long leafletId;
|
||||
private String ddNumber;
|
||||
private String reason;
|
||||
private String status;
|
||||
|
||||
/** Null until approved: no blank is consumed while the request is still pending. */
|
||||
private Long replacementLeafletId;
|
||||
private String replacementDdNumber;
|
||||
|
||||
/** The draft being moved, so the approval queue can be read on its own. */
|
||||
private String beneficiaryName;
|
||||
private String clientName;
|
||||
private BigDecimal faceValue;
|
||||
private String sponsorBankName;
|
||||
|
||||
private Long makerId;
|
||||
private String makerUsername;
|
||||
private LocalDateTime createdDate;
|
||||
private Long checkerId;
|
||||
private String checkerUsername;
|
||||
private LocalDateTime checkerDate;
|
||||
private String rejectionReason;
|
||||
}
|
||||
+4
@@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
|
||||
public interface SponsorDDReadPlatformService {
|
||||
|
||||
@@ -59,6 +60,9 @@ public interface SponsorDDReadPlatformService {
|
||||
|
||||
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
||||
|
||||
/** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */
|
||||
Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(String status);
|
||||
|
||||
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
|
||||
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
|
||||
|
||||
|
||||
+92
-6
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -75,6 +76,18 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Series order for a book of drafts.
|
||||
*
|
||||
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 — which handed the
|
||||
* clerk draft 10 immediately after draft 1 and showed the register out of order. Pre-printed
|
||||
* stationery has to be consumed in series, so a wholly numeric series is cast and ordered
|
||||
* numerically; a series carrying a prefix keeps text order, which is already its own sequence.
|
||||
*/
|
||||
private static final String LEAFLET_SERIES_ORDER =
|
||||
"order by case when leaf.dd_number ~ '^[0-9]+$' then cast(leaf.dd_number as bigint) end asc nulls last, "
|
||||
+ "leaf.dd_number asc";
|
||||
|
||||
private static final String LEAFLET_SELECT =
|
||||
"select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, "
|
||||
+ "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, "
|
||||
@@ -83,7 +96,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
||||
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
||||
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
|
||||
+ "amr.id as pending_amendment_request_id "
|
||||
+ "amr.id as pending_amendment_request_id, rvr.id as pending_revoke_request_id, "
|
||||
+ "leaf.revoked_replaced_by_leaflet_id, rby.dd_number as revoked_replaced_by_dd_number, "
|
||||
+ "leaf.revoked_replacement_of_leaflet_id, rof.dd_number as revoked_replacement_of_dd_number "
|
||||
+ "from m_sponsor_dd_leaflet leaf "
|
||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
||||
@@ -94,11 +109,20 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "left join m_appuser approver on approver.id = apr.checker_id "
|
||||
// Lets the registry show that a correction is already awaiting a manager, instead of
|
||||
// offering Edit again and letting a second request be raised over the same draft.
|
||||
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' ";
|
||||
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "
|
||||
// Same reason as the amendment join: the register must not offer Revoke twice over one
|
||||
// spoiled leaflet while a checker still has the first request.
|
||||
+ "left join m_sponsor_dd_revoke_request rvr on rvr.leaflet_id = leaf.id and rvr.status = 'PENDING' "
|
||||
// Both ends of a replacement, so the register can say where a revoked draft went and
|
||||
// what a replacement was issued against.
|
||||
+ "left join m_sponsor_dd_leaflet rby on rby.id = leaf.revoked_replaced_by_leaflet_id "
|
||||
+ "left join m_sponsor_dd_leaflet rof on rof.id = leaf.revoked_replacement_of_leaflet_id ";
|
||||
|
||||
@Override
|
||||
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
||||
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc";
|
||||
// Numeric-aware, for the same reason as retrieveNextAvailableLeaflet: a text sort shows the
|
||||
// register as 1, 10, 2, 3 and the book reads as though it were issued out of order.
|
||||
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? " + LEAFLET_SERIES_ORDER;
|
||||
return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId);
|
||||
}
|
||||
|
||||
@@ -170,11 +194,22 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final String approverUsername = rs.getString("approver_username");
|
||||
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
|
||||
? rs.getLong("pending_amendment_request_id") : null;
|
||||
final Long pendingRevokeRequestId = rs.getObject("pending_revoke_request_id") != null
|
||||
? rs.getLong("pending_revoke_request_id")
|
||||
: null;
|
||||
final Long revokedReplacedByLeafletId = rs.getObject("revoked_replaced_by_leaflet_id") != null
|
||||
? rs.getLong("revoked_replaced_by_leaflet_id")
|
||||
: null;
|
||||
final Long revokedReplacementOfLeafletId = rs.getObject("revoked_replacement_of_leaflet_id") != null
|
||||
? rs.getLong("revoked_replacement_of_leaflet_id")
|
||||
: null;
|
||||
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
||||
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
||||
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
||||
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
||||
approverUserId, approverUsername, pendingAmendmentRequestId);
|
||||
approverUserId, approverUsername, pendingAmendmentRequestId, pendingRevokeRequestId,
|
||||
revokedReplacedByLeafletId, rs.getString("revoked_replaced_by_dd_number"), revokedReplacementOfLeafletId,
|
||||
rs.getString("revoked_replacement_of_dd_number"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +449,54 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
return this.jdbcTemplate.query(sql, new ReprintRequestMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* The request, the draft it is about, and the blank that replaced it.
|
||||
*
|
||||
* <p>
|
||||
* Joined out to the leaflet and the client because approving this consumes a blank from limited
|
||||
* stock: the checker is deciding about a specific customer's draft, not an abstract request id.
|
||||
*/
|
||||
private static final String REVOKE_REQUEST_SELECT =
|
||||
"select rv.id, rv.leaflet_id, leaf.dd_number, rv.reason, rv.status, rv.replacement_leaflet_id, "
|
||||
+ "repl.dd_number as replacement_dd_number, leaf.beneficiary_name, "
|
||||
+ "coalesce(c.display_name, leaf.walkin_purchaser_name) as client_name, leaf.face_value, "
|
||||
+ "sb.name as sponsor_bank_name, rv.maker_id, maker.username as maker_username, rv.created_date, "
|
||||
+ "rv.checker_id, checker.username as checker_username, rv.checker_date, rv.rejection_reason "
|
||||
+ "from m_sponsor_dd_revoke_request rv "
|
||||
+ "join m_sponsor_dd_leaflet leaf on leaf.id = rv.leaflet_id "
|
||||
+ "left join m_sponsor_dd_leaflet repl on repl.id = rv.replacement_leaflet_id "
|
||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||
+ "left join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id "
|
||||
+ "left join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id "
|
||||
+ "join m_appuser maker on maker.id = rv.maker_id "
|
||||
+ "left join m_appuser checker on checker.id = rv.checker_id ";
|
||||
|
||||
@Override
|
||||
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(final String status) {
|
||||
if (status != null && !status.isBlank()) {
|
||||
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "where rv.status = ? order by rv.id desc",
|
||||
new RevokeRequestMapper(), status);
|
||||
}
|
||||
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "order by rv.id desc", new RevokeRequestMapper());
|
||||
}
|
||||
|
||||
private static final class RevokeRequestMapper implements RowMapper<SponsorDDRevokeRequestData> {
|
||||
|
||||
@Override
|
||||
public SponsorDDRevokeRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return new SponsorDDRevokeRequestData(rs.getLong("id"), rs.getLong("leaflet_id"), rs.getString("dd_number"),
|
||||
rs.getString("reason"), rs.getString("status"),
|
||||
rs.getObject("replacement_leaflet_id") != null ? rs.getLong("replacement_leaflet_id") : null,
|
||||
rs.getString("replacement_dd_number"), rs.getString("beneficiary_name"), rs.getString("client_name"),
|
||||
rs.getBigDecimal("face_value"), rs.getString("sponsor_bank_name"), rs.getLong("maker_id"),
|
||||
rs.getString("maker_username"),
|
||||
rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null,
|
||||
rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null, rs.getString("checker_username"),
|
||||
rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null,
|
||||
rs.getString("rejection_reason"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> {
|
||||
@Override
|
||||
public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
@@ -502,7 +585,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
sql += " AND leaf.advice_status = ? ";
|
||||
params.add(adviceStatus);
|
||||
}
|
||||
sql += " ORDER BY leaf.dd_number ASC";
|
||||
sql += " " + LEAFLET_SERIES_ORDER;
|
||||
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
||||
}
|
||||
|
||||
@@ -526,7 +609,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
sql.append("and inv.sponsor_bank_id = ? ");
|
||||
params.add(sponsorBankId);
|
||||
}
|
||||
sql.append("order by inv.id asc, leaf.dd_number asc limit 1");
|
||||
// Oldest book first, then strictly up its own series — see LEAFLET_SERIES_ORDER.
|
||||
sql.append("order by inv.id asc, ");
|
||||
sql.append(LEAFLET_SERIES_ORDER.substring("order by ".length()));
|
||||
sql.append(" limit 1");
|
||||
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
/**
|
||||
* Replacing a draft whose leaflet was spoiled at the printer.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from a reprint, which puts the same draft through the printer again on the same leaflet.
|
||||
* Here the paper itself is ruined: its printed number can never be handed to anyone, so the draft
|
||||
* has to move onto the next blank in the pad. Both leaflets are then accounted for - one spoiled,
|
||||
* one carrying the draft - which is what lets the branch's stock reconcile against the pad the
|
||||
* sponsor bank sent.
|
||||
*
|
||||
* <p>
|
||||
* No money moves. The customer was debited when the draft was issued and is still owed exactly one
|
||||
* draft for exactly that amount; only the instrument changes.
|
||||
*/
|
||||
public interface SponsorDDRevokeRequestWritePlatformService {
|
||||
|
||||
/** Raised by the branch that spoiled the leaflet. Nothing moves until a checker approves. */
|
||||
CommandProcessingResult requestRevoke(Long leafletId, String reason);
|
||||
|
||||
/**
|
||||
* Marks the spoiled leaflet REVOKED and moves its draft onto the next available blank.
|
||||
*
|
||||
* @return the request id, with the replacement leaflet as the sub-entity
|
||||
*/
|
||||
CommandProcessingResult approveRevokeRequest(Long revokeRequestId);
|
||||
|
||||
CommandProcessingResult rejectRevokeRequest(Long revokeRequestId, String rejectionReason);
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SponsorDDRevokeRequestWritePlatformServiceImpl implements SponsorDDRevokeRequestWritePlatformService {
|
||||
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_APPROVED = "APPROVED";
|
||||
private static final String STATUS_REJECTED = "REJECTED";
|
||||
private static final String LEAFLET_AVAILABLE = "AVAILABLE";
|
||||
private static final String LEAFLET_ISSUED = "ISSUED";
|
||||
private static final String LEAFLET_REVOKED = "REVOKED";
|
||||
|
||||
/**
|
||||
* Series order for pre-printed stationery.
|
||||
*
|
||||
* <p>
|
||||
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 - which handed out
|
||||
* draft 10 as the replacement for draft 1 while drafts 2 to 9 sat unused, and left the pad
|
||||
* consumed out of sequence with no way to reconcile it against the sponsor bank's range. A
|
||||
* wholly numeric series is cast and ordered numerically; a series carrying a prefix keeps text
|
||||
* order, which is already its own sequence.
|
||||
*/
|
||||
private static final String SERIES_ORDER =
|
||||
"case when l.dd_number ~ '^[0-9]+$' then cast(l.dd_number as bigint) end asc nulls last, l.dd_number asc";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult requestRevoke(final Long leafletId, final String reason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
|
||||
|
||||
if (!LEAFLET_ISSUED.equals(leaflet.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Only an issued Demand Draft can be revoked. Current status: " + leaflet.get("status"));
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("A reason is required to revoke a spoiled Demand Draft.");
|
||||
}
|
||||
if (!this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_sponsor_dd_revoke_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
|
||||
.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"A revoke request is already pending for this Demand Draft. Wait for it to be approved or rejected.");
|
||||
}
|
||||
|
||||
// Checked at request time as well as at approval: a clerk who has run out of blank drafts
|
||||
// needs to know now, while the customer is still at the counter, rather than after a
|
||||
// checker has been asked to approve something that cannot be carried out.
|
||||
if (findReplacementLeaflet(leafletId) == null) {
|
||||
throw new IllegalStateException("No blank Demand Draft is available to replace this one - "
|
||||
+ "not in this series, elsewhere in this sponsor bank's stock, or with any other sponsor bank. "
|
||||
+ "Add fresh DD inventory before revoking.");
|
||||
}
|
||||
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(con -> {
|
||||
final java.sql.PreparedStatement ps = con.prepareStatement(
|
||||
"INSERT INTO m_sponsor_dd_revoke_request (leaflet_id, reason, status, maker_id, created_date) "
|
||||
+ "VALUES (?, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, leafletId);
|
||||
ps.setString(2, reason);
|
||||
ps.setString(3, STATUS_PENDING);
|
||||
ps.setLong(4, currentUser.getId());
|
||||
ps.setObject(5, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
return new CommandProcessingResultBuilder().withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult approveRevokeRequest(final Long revokeRequestId) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot approve your own revoke request.");
|
||||
}
|
||||
|
||||
final Long spoiledId = ((Number) request.get("leaflet_id")).longValue();
|
||||
final Map<String, Object> spoiled = findLeafletOrThrow(spoiledId);
|
||||
if (!LEAFLET_ISSUED.equals(spoiled.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"This Demand Draft is no longer issued. Current status: " + spoiled.get("status"));
|
||||
}
|
||||
|
||||
// Re-checked here rather than trusted from request time: stock moves, and the blank that was
|
||||
// free when the clerk asked may have been issued to someone else in the meantime.
|
||||
final Map<String, Object> replacement = findReplacementLeaflet(spoiledId);
|
||||
if (replacement == null) {
|
||||
throw new IllegalStateException("No blank Demand Draft is available to replace this one. "
|
||||
+ "Add fresh DD inventory, then approve this request.");
|
||||
}
|
||||
final Long replacementId = ((Number) replacement.get("id")).longValue();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
// The draft moves wholesale onto the new leaflet - same customer, same beneficiary, same
|
||||
// amount. Nothing is recalculated: the customer was debited once, for this draft, and is
|
||||
// owed exactly this draft on whatever paper the branch can actually print.
|
||||
this.jdbcTemplate.update("UPDATE m_sponsor_dd_leaflet SET status = ?, face_value = ?, issued_to_client_id = ?, "
|
||||
+ "beneficiary_name = ?, issuing_branch_id = ?, issued_date = ?, walkin_purchaser_name = ?, "
|
||||
+ "walkin_purchaser_address = ?, walkin_purchaser_aadhaar_masked = ?, "
|
||||
+ "revoked_replacement_of_leaflet_id = ?, lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||
LEAFLET_ISSUED, spoiled.get("face_value"), spoiled.get("issued_to_client_id"),
|
||||
spoiled.get("beneficiary_name"), spoiled.get("issuing_branch_id"), spoiled.get("issued_date"),
|
||||
spoiled.get("walkin_purchaser_name"), spoiled.get("walkin_purchaser_address"),
|
||||
spoiled.get("walkin_purchaser_aadhaar_masked"), spoiledId, currentUser.getId(), now, replacementId);
|
||||
|
||||
// The spoiled leaflet keeps its beneficiary and amount. It is a record of a draft that was
|
||||
// printed and destroyed, not an empty slot, and blanking it would leave the pad short by one
|
||||
// with nothing to say where it went.
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_leaflet SET status = ?, revoked_replaced_by_leaflet_id = ?, "
|
||||
+ "lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||
LEAFLET_REVOKED, replacementId, currentUser.getId(), now, spoiledId);
|
||||
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_revoke_request SET status = ?, replacement_leaflet_id = ?, checker_id = ?, "
|
||||
+ "checker_date = ? WHERE id = ?",
|
||||
STATUS_APPROVED, replacementId, currentUser.getId(), now, revokeRequestId);
|
||||
|
||||
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).withSubEntityId(replacementId).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CommandProcessingResult rejectRevokeRequest(final Long revokeRequestId, final String rejectionReason) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new IllegalStateException("You cannot reject your own revoke request.");
|
||||
}
|
||||
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_sponsor_dd_revoke_request SET status = ?, checker_id = ?, checker_date = ?, "
|
||||
+ "rejection_reason = ? WHERE id = ?",
|
||||
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, revokeRequestId);
|
||||
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The blank that should carry the draft instead, searched in the order a branch would reach for
|
||||
* one.
|
||||
*
|
||||
* <p>
|
||||
* Nearest first: the next number in the same pad, so the series stays unbroken and the sponsor
|
||||
* bank's stock reconciles against one continuous range. Then anywhere else in the same sponsor
|
||||
* bank's stock - a second pad from the same bank is still that bank's paper and its drafts are
|
||||
* honoured the same way. Only then another sponsor bank, which is a real fallback rather than a
|
||||
* preference: the draft is drawn on a different bank, so the branch would rather not, but a
|
||||
* customer standing at the counter needs a draft more than the branch needs a tidy series.
|
||||
*
|
||||
* <p>
|
||||
* Null when the branch has no blank draft anywhere, which is a stock problem and is reported as
|
||||
* one rather than silently issuing nothing.
|
||||
*/
|
||||
private Map<String, Object> findReplacementLeaflet(final Long spoiledLeafletId) {
|
||||
// The next number after the spoiled one, so the pad is consumed in the order it was
|
||||
// printed. Falls back to the lowest free number in the pad when the spoiled draft was the
|
||||
// last one, rather than skipping the series entirely.
|
||||
final List<Map<String, Object>> afterSpoiled = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "AND l.dd_number ~ '^[0-9]+$' "
|
||||
+ "AND cast(l.dd_number as bigint) > "
|
||||
+ " (SELECT case when s.dd_number ~ '^[0-9]+$' then cast(s.dd_number as bigint) else -1 end "
|
||||
+ " FROM m_sponsor_dd_leaflet s WHERE s.id = ?) "
|
||||
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId, spoiledLeafletId);
|
||||
if (!afterSpoiled.isEmpty()) {
|
||||
return afterSpoiled.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> sameInventory = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
if (!sameInventory.isEmpty()) {
|
||||
return sameInventory.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> sameBank = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||
+ "WHERE i.sponsor_bank_id = (SELECT i2.sponsor_bank_id FROM m_sponsor_dd_inventory i2 "
|
||||
+ " JOIN m_sponsor_dd_leaflet l2 ON l2.inventory_id = i2.id WHERE l2.id = ?) "
|
||||
+ "AND l.status = ? AND l.id <> ? "
|
||||
+ "ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
if (!sameBank.isEmpty()) {
|
||||
return sameBank.get(0);
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> anyBank = this.jdbcTemplate.queryForList(
|
||||
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||
+ "WHERE l.status = ? AND l.id <> ? ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||
LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||
return anyBank.isEmpty() ? null : anyBank.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Demand Draft leaflet not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> findRevokeRequestOrThrow(final Long revokeRequestId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_sponsor_dd_revoke_request WHERE id = ?", revokeRequestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("Sponsor DD revoke request not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private boolean isCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
}
|
||||
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (!isCheckerRole(user)) {
|
||||
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject revoke requests.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsInterestReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/tds/deposit-interest")
|
||||
@Component
|
||||
@Tag(name = "TDS on deposit interest",
|
||||
description = "Interest credited on deposits in a financial year, per customer, for the section 194A deduction.")
|
||||
@RequiredArgsConstructor
|
||||
public class TdsInterestApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final TdsInterestReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<TdsDeducteeData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
/**
|
||||
* The financial year the books are currently in, off the business date rather than the wall
|
||||
* clock, so a back-dated working day reports the year that day belongs to.
|
||||
*/
|
||||
private int currentFinancialYear() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
return businessDate.getMonthValue() >= 4 ? businessDate.getYear() : businessDate.getYear() - 1;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
* their own {@code depositTypeCode} - the caller keeps them out of the aggregate and shows them
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account and the interest posted to it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
|
||||
public interface TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Deposit interest credited in a financial year, per customer.
|
||||
*
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
*
|
||||
* <p>
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
*
|
||||
* <p>
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
*
|
||||
* <p>
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
o.name AS branch_name,
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
}
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
d.form15Type, accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
return value == null ? null : value.toLocalDate();
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable accumulators, private to the grouping above. */
|
||||
private static final class Deductee {
|
||||
|
||||
private final Long clientId;
|
||||
private final String customerId;
|
||||
private final String name;
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Account {
|
||||
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<!--
|
||||
Brings the last two accounts of the dividend lifecycle under Financial Activity Mappings.
|
||||
|
||||
The dividend cycle reads its GL accounts from acc_gl_financial_activity_account, except for the two
|
||||
profit accounts, which five classes found by hardcoded GL code:
|
||||
|
||||
BANKPROFITND01 the year's closed-out profit a dividend is declared against
|
||||
BANKPROFIT01 where profit settles once it is no longer distributable - the remainder after an
|
||||
approved declaration, the rounding residue, and dividends forfeited after the
|
||||
three-year claim window
|
||||
|
||||
A society whose chart of accounts did not happen to use those exact codes got no dividend journal
|
||||
entries at all, and nothing reported it: every call site checked for null and returned quietly.
|
||||
They are now FinancialActivity DISTRIBUTABLE_PROFIT (302) and RETAINED_PROFIT (303), configurable
|
||||
like every other account in the cycle, and resolved through DividendGlAccountResolver - which
|
||||
throws rather than posting nothing when a mapping is absent.
|
||||
|
||||
This changeset maps them to whatever those two GL codes resolve to today, so an existing society
|
||||
keeps the exact accounts it has been posting to and needs to do nothing. Where a code does not
|
||||
exist, no row is written: there is no correct account to guess at, and the resolver will name the
|
||||
activity to configure the first time the cycle runs.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0539-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0539-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<!--
|
||||
Gives the paise a dividend cannot divide evenly an account of their own.
|
||||
|
||||
A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 belonging to nobody. That residue was swept
|
||||
into Bank Profit alongside the undistributed remainder, which put an arithmetic artefact inside a figure the society
|
||||
reads as a real business result - and left an auditor asking why members received less than was declared with no
|
||||
single balance to point at.
|
||||
|
||||
Seeded and mapped here so the separation is in place from the first declaration after this runs, and editable from
|
||||
Accounting > Financial Activity Mappings afterwards like every other dividend account. A society that would rather
|
||||
not separate it can delete the mapping: the residue then sweeps to retained profit exactly as it did before.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0544-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01'</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
Equity, matching the profit accounts it sits beside: the residue is the society's own money, retained
|
||||
rather than earned, so recognising it as income would overstate the year's result by the rounding.
|
||||
-->
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Dividend Round Off"/>
|
||||
<column name="gl_code" value="DIVROUNDOFF01"/>
|
||||
<column name="disabled" valueBoolean="false"/>
|
||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="3"/>
|
||||
<column name="description" value="Fractions left over when a declared dividend cannot be divided evenly across members' shares. Swept here when the declaration is approved, so the rounding stays readable apart from the society's distributable and retained profit."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0544-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 304</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 304, id FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<!--
|
||||
Makes sure the two profit accounts the dividend cycle runs on actually exist and are mapped.
|
||||
|
||||
0539 mapped them with "SELECT <activity>, id FROM acc_gl_account WHERE gl_code = ...", which inserts nothing at all
|
||||
when the account is absent - no row, no error. BANKPROFITND01 is seeded by 0490, but BANKPROFIT01 never was: it was
|
||||
assumed to be part of every tenant's chart already. A tenant without it got no mapping for RETAINED_PROFIT, and the
|
||||
omission only surfaced as a missing row on the Financial Activity Mappings screen.
|
||||
|
||||
Seeding the account first and mapping second removes that dependency on what a tenant happens to have. Both accounts
|
||||
stay editable afterwards - a society that keeps its profit elsewhere just repoints the mapping, and this file's
|
||||
preconditions leave any existing mapping untouched.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0545-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<!-- Matched on name as well as code: 0503 renumbered accounts by name, so a chart where it ran holds
|
||||
this account under a numeric code, and checking the code alone would create a duplicate. -->
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Bank Profit"/>
|
||||
<column name="gl_code" value="BANKPROFIT01"/>
|
||||
<column name="disabled" valueBoolean="false"/>
|
||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="3"/>
|
||||
<column name="description" value="Profit that has already been through a dividend decision: the undistributed remainder moved here when a declaration was approved, and dividends forfeited after the three-year claim window. Never enters a later dividend calculation."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
0545-2 and 0545-3 below are left exactly as they first ran. They matched on gl_code and inserted nothing,
|
||||
because 0503 had already renumbered one of the accounts - see 0545-4. Editing them in place would change their
|
||||
checksum and Liquibase refuses to re-validate a changeset that has already been applied, so the corrected
|
||||
lookup is a new pair of changesets rather than a rewrite of these.
|
||||
-->
|
||||
|
||||
<!-- Distributable Profit (302) - what a dividend is declared against. -->
|
||||
<changeSet author="fineract" id="0545-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Retained Profit (303) - where profit settles once it is no longer distributable. -->
|
||||
<changeSet author="fineract" id="0545-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The same two mappings, looked up by name.
|
||||
|
||||
Every attempt so far searched for gl_code 'BANKPROFITND01' and found nothing, because 0503 standardised the
|
||||
chart onto numeric codes with "UPDATE acc_gl_account SET gl_code = '300903' WHERE name = 'Bank Profit
|
||||
(Non-Distributable)'". The account is still there under code 300903; only the code it was being looked up by
|
||||
had gone. 'BANKPROFIT01' was not part of that rename and still carries its original code, which is why the two
|
||||
sit on inconsistent codes today.
|
||||
|
||||
The name survived the renumbering, so that is what these match on, with gl_code kept as an alternative for a
|
||||
chart where 0503 never ran. Both remain no-ops where a mapping already exists, so a society that has already
|
||||
pointed either activity at its own account keeps that choice.
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0545-4">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 302, id FROM acc_gl_account
|
||||
WHERE name = 'Bank Profit (Non-Distributable)' OR gl_code = 'BANKPROFITND01'
|
||||
ORDER BY id LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0545-5">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
|
||||
SELECT 303, id FROM acc_gl_account
|
||||
WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01'
|
||||
ORDER BY id LIMIT 1;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- An AML rule could only say "what amount"; it could not say which way the money moved, or
|
||||
whether it had to be cash, or how many transactions make a pattern. Every active rule was
|
||||
therefore matched on amount alone, and one ₹12,00,000 cash deposit raised ten alerts: the
|
||||
genuine CTR hit, plus "frequent low cash WITHDRAWAL", "structuring BELOW ₹50,000",
|
||||
"charitable organisation", "PEP account" and five more that had nothing to do with it.
|
||||
|
||||
An analyst who cannot tell the one real alert from the nine noise ones stops reading them,
|
||||
and that is how a real suspicious transaction gets missed. These three columns let a rule
|
||||
describe the transaction it is actually about. -->
|
||||
|
||||
<changeSet id="0546-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="direction"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- CREDIT / DEBIT, or NULL for a rule that applies either way. -->
|
||||
<column name="direction" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="cash_only"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- A "cash transaction report" rule must not fire on a transfer or a cheque. -->
|
||||
<column name="cash_only" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="min_transaction_count"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- Structuring is many small transactions, not one large one. Without a floor on the
|
||||
count, a single deposit whose amount happens to clear the window threshold reads as
|
||||
a structuring pattern. -->
|
||||
<column name="min_transaction_count" type="INT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0546-4" author="CBS">
|
||||
<comment>Describe each seeded rule properly, now that a rule can say more than an amount.</comment>
|
||||
<sql>
|
||||
<!-- Cash reporting rules are about cash, in either direction. -->
|
||||
UPDATE m_aml_transaction_rule SET cash_only = true
|
||||
WHERE rule_code IN ('CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L',
|
||||
'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K', 'FREQUENT_CASH_NEAR_10L',
|
||||
'FREQUENT_LOW_CASH_DEPOSIT', 'FREQUENT_LOW_CASH_WITHDRAWAL',
|
||||
'CHARITABLE_ORG_CASH_WITHDRAWAL', 'LOAN_REPAYMENT_IN_CASH');
|
||||
|
||||
<!-- Rules whose own name says which way the money went. -->
|
||||
UPDATE m_aml_transaction_rule SET direction = 'CREDIT'
|
||||
WHERE rule_code IN ('FREQUENT_LOW_CASH_DEPOSIT', 'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K',
|
||||
'FREQUENT_CASH_NEAR_10L');
|
||||
UPDATE m_aml_transaction_rule SET direction = 'DEBIT'
|
||||
WHERE rule_code IN ('FREQUENT_LOW_CASH_WITHDRAWAL', 'CHARITABLE_ORG_CASH_WITHDRAWAL',
|
||||
'LOAN_REPAYMENT_IN_CASH');
|
||||
|
||||
<!-- Three or more in the window before a pattern is called structuring: two large
|
||||
deposits in a month is ordinary business, and RBI's own wording is "numerous". -->
|
||||
UPDATE m_aml_transaction_rule SET min_transaction_count = 3
|
||||
WHERE rule_type IN ('STRUCTURING', 'VELOCITY');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Direction, cash and count (0541) described the TRANSACTION. Some rules are instead about
|
||||
WHO is transacting: "PEP account monitoring" is about politically exposed persons,
|
||||
"charitable organisation cash withdrawal" about trusts and societies, "jeweller account"
|
||||
about dealers in precious metal. None of that was checked, so each of them read as a plain
|
||||
amount rule and fired on every ordinary customer who crossed ₹50,000 - PEP_ACCOUNT_MONITORING
|
||||
was raised against an individual salaried customer for exactly this reason.
|
||||
|
||||
An entity rule that cannot identify its entity must raise nothing. Reporting every customer
|
||||
as a PEP is not the safe direction: it buries the real ones. -->
|
||||
|
||||
<changeSet id="0547-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="subject_scope"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<!-- PEP / CHARITABLE_ORG / HIGH_RISK_BUSINESS / NEW_ACCOUNT, or NULL for a rule that is
|
||||
about the transaction rather than the customer behind it. -->
|
||||
<column name="subject_scope" type="VARCHAR(30)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0547-2" author="CBS">
|
||||
<comment>Scope the four entity rules to the customers they are actually written about.</comment>
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'PEP'
|
||||
WHERE rule_code = 'PEP_ACCOUNT_MONITORING';
|
||||
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'CHARITABLE_ORG'
|
||||
WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL';
|
||||
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'HIGH_RISK_BUSINESS'
|
||||
WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN';
|
||||
|
||||
<!-- "Large transaction on a NEW account" - the age of the account is the whole point of
|
||||
the rule, and it was the one thing not being looked at. -->
|
||||
UPDATE m_aml_transaction_rule SET subject_scope = 'NEW_ACCOUNT'
|
||||
WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- A sponsor bank sends a limited pad of blank drafts, and a leaflet spoiled in the printer is
|
||||
gone: the paper cannot be re-used, and the number printed on it cannot be given to anyone
|
||||
else. The branch has to hand the customer the next blank instead.
|
||||
|
||||
Reprint does not cover this. Reprint puts the same draft back through the printer on the
|
||||
same leaflet - right when the print jammed but the paper survived. Here the paper itself is
|
||||
spoiled, so a different leaflet has to carry the draft, and the register must show which
|
||||
one replaced which or the stock will not reconcile against the sponsor bank's pad. -->
|
||||
|
||||
<changeSet id="0548-1" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_sponsor_dd_revoke_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_sponsor_dd_revoke_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- The spoiled leaflet. -->
|
||||
<column name="leaflet_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- The blank that carried the draft instead. Filled on approval, because until a
|
||||
checker agrees no stock has moved and nothing has been consumed. -->
|
||||
<column name="replacement_leaflet_id" type="BIGINT"/>
|
||||
<column name="reason" type="VARCHAR(500)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="maker_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_date" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-2" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_dd_revoke_leaflet"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_sponsor_dd_revoke_request" baseColumnNames="leaflet_id"
|
||||
constraintName="fk_dd_revoke_leaflet" referencedTableName="m_sponsor_dd_leaflet"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-3" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="fk_dd_revoke_replacement"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_sponsor_dd_revoke_request" baseColumnNames="replacement_leaflet_id"
|
||||
constraintName="fk_dd_revoke_replacement" referencedTableName="m_sponsor_dd_leaflet"
|
||||
referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-4" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_dd_revoke_status"/></not>
|
||||
</preConditions>
|
||||
<comment>The approval queue reads by status; the register reads by leaflet.</comment>
|
||||
<createIndex tableName="m_sponsor_dd_revoke_request" indexName="idx_dd_revoke_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-5" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_dd_revoke_leaflet"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_sponsor_dd_revoke_request" indexName="idx_dd_revoke_leaflet">
|
||||
<column name="leaflet_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-6" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_sponsor_dd_leaflet" columnName="revoked_replaced_by_leaflet_id"/></not>
|
||||
</preConditions>
|
||||
<comment>
|
||||
Carried on the leaflet as well as on the request, so the register can show "replaced by
|
||||
DD 000002" without joining through a request row that a purge might one day remove.
|
||||
</comment>
|
||||
<addColumn tableName="m_sponsor_dd_leaflet">
|
||||
<column name="revoked_replaced_by_leaflet_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0548-7" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_sponsor_dd_leaflet" columnName="revoked_replacement_of_leaflet_id"/></not>
|
||||
</preConditions>
|
||||
<comment>The other direction: a replacement says which spoiled leaflet it stands in for.</comment>
|
||||
<addColumn tableName="m_sponsor_dd_leaflet">
|
||||
<column name="revoked_replacement_of_leaflet_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user