From 1c6ac9d77fb13286e98c043bea5927eb062d2f42 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 14 Sep 2026 11:46:16 +0530 Subject: [PATCH] feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module --- ...FinancialActivityAccountDataValidator.java | 29 +- .../common/AccountingConstants.java | 27 +- .../aml/domain/AmlTransactionRule.java | 43 +++ .../common/DividendGlAccountResolver.java | 112 +++++++ .../api/DistributableProfitApiResource.java | 14 +- .../api/IncomeProfitTransferApiResource.java | 2 +- .../service/IncomeProfitTransferService.java | 47 ++- .../aml/api/AmlCtrReportApiResource.java | 14 + .../aml/data/AmlCtrReportLineData.java | 50 +++ .../portfolio/aml/data/AmlDirection.java | 45 +++ .../portfolio/aml/data/AmlSubjectScope.java | 64 ++++ .../aml/data/AmlTransactionContext.java | 56 ++++ .../aml/event/AmlDomainServiceImpl.java | 29 +- .../AmlCtrReportReadPlatformService.java | 9 + .../AmlCtrReportReadPlatformServiceImpl.java | 59 ++++ .../service/AmlRuleEvaluationServiceImpl.java | 120 +++++++- .../aml/service/AmlSubjectScopeResolver.java | 136 +++++++++ .../api/DividendClaimRequestApiResource.java | 42 +-- .../ShareAccountSchedularServiceImpl.java | 19 +- .../start/ShareAccountsConfiguration.java | 5 +- .../api/AllProductsDividendApiResource.java | 22 +- ...WritePlatformServiceJpaRepositoryImpl.java | 132 +++----- .../starter/ShareProductsConfiguration.java | 7 +- .../sponsordd/api/SponsorDDApiResource.java | 46 +++ .../sponsordd/data/SponsorDDLeafletData.java | 16 + .../data/SponsorDDRevokeRequestData.java | 66 ++++ .../service/SponsorDDReadPlatformService.java | 4 + .../SponsorDDReadPlatformServiceImpl.java | 98 +++++- ...orDDRevokeRequestWritePlatformService.java | 50 +++ ...RevokeRequestWritePlatformServiceImpl.java | 288 ++++++++++++++++++ .../tds/api/TdsInterestApiResource.java | 70 +++++ .../portfolio/tds/data/TdsDeducteeData.java | 54 ++++ .../TdsInterestReadPlatformService.java | 34 +++ .../TdsInterestReadPlatformServiceImpl.java | 206 +++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 3 + .../tenant/final-changelog-tenant.xml | 3 + .../0539_map_profit_financial_activities.xml | 66 ++++ ..._dividend_round_off_financial_activity.xml | 65 ++++ ...ure_profit_financial_activities_mapped.xml | 120 ++++++++ ...0546_aml_rule_direction_and_cash_scope.xml | 76 +++++ .../parts/0547_aml_rule_subject_scope.xml | 47 +++ .../parts/0548_sponsor_dd_revoke_request.xml | 109 +++++++ 42 files changed, 2319 insertions(+), 185 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java index e69cddba7..b978047ff 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java @@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator { */ private final Set 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)) { diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index 655527918..a5a4ead03 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java index acf7ffb7a..aa11a2ba9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java @@ -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() {} /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java new file mode 100644 index 000000000..67f81c924 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java @@ -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. + * + *

+ * 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. + * + *

+ * 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java index afb530e25..db12703e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java index abae1f99f..2e0860e97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java @@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource { ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE : incomeGlCodeParam.trim(); final Map incomeGl = service.loadGlByCode(incomeGlCode); - final Map profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE); + final Map profitGl = service.loadDistributableProfitGl(); final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear() : financialYearParam.trim(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 464ddee50..42b55ec4b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -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; * *

  *   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)
  * 
* * 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 incomeGl = loadGlByCode(incomeGlCode); - final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map 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 closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note, final boolean strict) { - final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map 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 profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map 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 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 loadDistributableProfitGl() { + return loadGlById(this.dividendGlAccounts.distributableProfitAccount()); + } + public Long headOfficeId() { return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java index e812d95d6..08289700e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java @@ -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 toApiJsonSerializer; + private final DefaultToApiJsonSerializer 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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java new file mode 100644 index 000000000..2822c074c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java @@ -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. + * + *

+ * 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. + * + *

+ * 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 {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java new file mode 100644 index 000000000..41a063311 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java @@ -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. + * + *

+ * 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java new file mode 100644 index 000000000..c8ff16e98 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java @@ -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. + * + *

+ * 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; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java index 4f7b66fa9..7a700e99b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java @@ -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 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 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 getWindowAmounts() { + return windowAmounts; + } + + public LocalDate getSubjectOpenedOn() { + return subjectOpenedOn; } public AmlTransactionType getTransactionType() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java index 17698fcbe..1a575b614 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java @@ -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 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 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 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 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 repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList(); + final List 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java index 0a6e9ddd4..6a1dfabcf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java @@ -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 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 retrieveLines(Long reportId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java index 194c237d1..ecd0dc29e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java @@ -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 retrieveAll() { @@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat .map(this::mapToData).collect(Collectors.toList()); } + @Override + public List retrieveLines(final Long reportId) { + final StringBuilder sql = new StringBuilder(LINES_SQL); + final List 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()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 843a436d5..2c8023e39 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -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 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. + * + *

+ * 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. + * + *

+ * 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 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. + * + *

+ * 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 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. + * + *

+ * 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. + * + *

+ * 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java new file mode 100644 index 000000000..cea777c2f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java @@ -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. + * + *

+ * 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 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 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. + * + *

+ * 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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index eb00771ee..2c675b96e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index b735ef91a..630665737 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index bc32af30f..f7b06aeb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 7d6b5e5f8..0b7f8fe02 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index bbfa1822a..cb8117875 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java index 25c028700..e8998825c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index ac77c6bb0..5a272502f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java index ac63e0d96..4916322df 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -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. + * + *

+ * 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java new file mode 100644 index 000000000..29ed7fa4c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java @@ -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. + * + *

+ * 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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java index d427ab959..02f9e30ae 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -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 retrieveAllReprintRequests(String status); + /** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */ + Collection retrieveAllRevokeRequests(String status); + /** DD amendment requests for the approval queue, optionally narrowed to one status. */ Collection retrieveAllAmendmentRequests(String status); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 471e767d9..cf305c1e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -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 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. + * + *

+ * 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 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 { + + @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 { @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()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java new file mode 100644 index 000000000..4aa8690c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java @@ -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. + * + *

+ * 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. + * + *

+ * 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..602fd7476 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java @@ -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. + * + *

+ * {@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 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 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 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 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 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. + * + *

+ * 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. + * + *

+ * 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 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> 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> 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> 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> 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 findLeafletOrThrow(final Long leafletId) { + final List> 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 findRevokeRequestOrThrow(final Long revokeRequestId) { + final List> 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."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java new file mode 100644 index 000000000..d3dff7274 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java @@ -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 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java new file mode 100644 index 000000000..57819017f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java @@ -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. + * + *

+ * 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 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 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 {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java new file mode 100644 index 000000000..1576d8ec0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java @@ -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 retrieveForFinancialYear(int financialYear); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java new file mode 100644 index 000000000..379a553a0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java @@ -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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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 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 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 result = new ArrayList<>(); + for (final Deductee d : byClient.values()) { + final List 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 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 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; + } + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index e75b002f7..b77490e94 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -612,4 +612,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 6b737c847..0b6a6606c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -67,4 +67,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml new file mode 100644 index 000000000..5b19c15a0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml @@ -0,0 +1,66 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01'; + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml new file mode 100644 index 000000000..39f3d53f2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml @@ -0,0 +1,65 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01' + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 304 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 304, id FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml new file mode 100644 index 000000000..12a9f3091 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml @@ -0,0 +1,120 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01' + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01'; + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01'; + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + 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; + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml new file mode 100644 index 000000000..3c56ff70f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Describe each seeded rule properly, now that a rule can say more than an amount. + + + 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'); + + + 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'); + + + UPDATE m_aml_transaction_rule SET min_transaction_count = 3 + WHERE rule_type IN ('STRUCTURING', 'VELOCITY'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml new file mode 100644 index 000000000..05b23bddc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + Scope the four entity rules to the customers they are actually written about. + + 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'; + + + UPDATE m_aml_transaction_rule SET subject_scope = 'NEW_ACCOUNT' + WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml new file mode 100644 index 000000000..56dfdb29a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The approval queue reads by status; the register reads by leaflet. + + + + + + + + + + + + + + + + + + + + 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. + + + + + + + + + + + The other direction: a replacement says which spoiled leaflet it stands in for. + + + + + +