diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index e8c01f135..ad0836c81 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -35,4 +35,10 @@ public interface AmlAlertRepository extends JpaRepository { @Param("since") LocalDateTime since); long countByClientId(Long clientId); + + /** + * Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked + * twice, which would put two STR cases in front of the Principal Officer for a single transaction. + */ + List findByRule_IdAndTransactionId(Long ruleId, Long transactionId); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java index f654d5672..19b5eae0b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java @@ -19,9 +19,17 @@ package org.apache.fineract.portfolio.aml.domain; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface AmlTransactionRuleRepository extends JpaRepository { List findByActiveTrueAndAppliesTo(String appliesTo); + + /** + * Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately + * seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises + * by hand from the ledger. + */ + Optional findByRuleCode(String ruleCode); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java index 9fe458bf0..498738d70 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java @@ -33,8 +33,10 @@ 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.AmlAlertData; +import org.apache.fineract.portfolio.aml.data.ManualReferralData; import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService; import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService; +import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService; import org.springframework.stereotype.Component; @Path("/v1/aml/alerts") @@ -46,7 +48,9 @@ public class AmlAlertApiResource { private final PlatformSecurityContext context; private final AmlAlertReadPlatformService readPlatformService; private final AmlAlertWritePlatformService writePlatformService; + private final JournalEntryReferralService referralService; private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final DefaultToApiJsonSerializer referralJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -76,4 +80,28 @@ public class AmlAlertApiResource { .orElse(null); return this.toApiJsonSerializer.serialize(updated); } + + @GET + @Path("manual-referrals") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List ledger entries marked by a user", + description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.") + public String retrieveManualReferrals() { + this.context.authenticatedUser(); + return this.referralJsonSerializer.serialize(this.referralService.retrieveAll()); + } + + @POST + @Path("manual-referral") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Mark a ledger entry cleared or suspicious", + description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.") + public String referJournalEntry(final ManualReferralRequest request) { + this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT"); + final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(), + request.getNotes()); + return this.referralJsonSerializer.serialize(referral); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java new file mode 100644 index 000000000..c6f4381b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java @@ -0,0 +1,39 @@ +/** + * 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.api; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Request body for {@code POST /v1/aml/alerts/manual-referral}. + * + * {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared, + * no report) or ESCALATE (raises an STR case for the Principal Officer). + */ +@Getter +@Setter +@NoArgsConstructor +public class ManualReferralRequest { + + private Long journalEntryId; + private String disposition; + private String notes; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java new file mode 100644 index 000000000..d990d4825 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java @@ -0,0 +1,33 @@ +/** + * 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.LocalDateTime; + +/** + * One ledger entry a user has marked, so the ledger screen can show what has already been dealt with. + * + * {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys + * on. {@code strCaseId} is populated only where the mark escalated into an STR case. + */ +public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName, + BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition, + String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java new file mode 100644 index 000000000..77f9e0ed8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java @@ -0,0 +1,39 @@ +/** + * 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.util.List; +import org.apache.fineract.portfolio.aml.data.ManualReferralData; + +/** + * Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen. + * + * The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule + * fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an + * {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the + * case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running. + */ +public interface JournalEntryReferralService { + + /** disposition: WHITELIST | ESCALATE. */ + ManualReferralData refer(Long journalEntryId, String disposition, String notes); + + /** Every mark made from the ledger, so the screen can show what has already been dealt with. */ + List retrieveAll(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java new file mode 100644 index 000000000..db4ec204d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -0,0 +1,224 @@ +/** + * 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.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.aml.data.ManualReferralData; +import org.apache.fineract.portfolio.aml.domain.AmlAlert; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.aml.domain.AmlStrCase; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class JournalEntryReferralServiceImpl implements JournalEntryReferralService { + + /** Seeded inactive by 0443 so the evaluation engine never fires it. */ + private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL"; + + private final JdbcTemplate jdbcTemplate; + private final AmlAlertRepository alertRepository; + private final AmlStrCaseRepository strCaseRepository; + private final AmlTransactionRuleRepository ruleRepository; + private final ClientRepositoryWrapper clientRepository; + private final PlatformSecurityContext context; + private final ClientRiskEscalationService clientRiskEscalationService; + + /** + * A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is + * posted against. + * + * The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how + * JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an + * account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and + * a savings-type entity may be either a savings or a current account, which live in separate tables. + * + * The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL + * posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable. + */ + private static final String RESOLVE_SQL = """ + SELECT je.amount AS amount, + je.currency_code AS currencyCode, + je.savings_transaction_id AS savingsTxnId, + je.loan_transaction_id AS loanTxnId, + je.entity_type_enum AS entityType, + COALESCE( + esa.client_id, + eca.client_id, + eloan.client_id, + CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, + sa.client_id, + l.client_id, + ct.client_id + ) AS clientId + FROM acc_gl_journal_entry je + LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1 + LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id + LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id + LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id + LEFT JOIN m_loan l ON l.id = lt.loan_id + LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id + WHERE je.id = ? + """; + + private static final String LIST_SQL = """ + SELECT a.id AS alertId, + a.transaction_id AS journalEntryId, + a.client_id AS clientId, + c.display_name AS clientDisplayName, + a.amount AS amount, + a.currency_code AS currencyCode, + a.raised_on AS raisedOn, + a.status AS status, + a.disposition AS disposition, + u.username AS reviewedByUsername, + a.review_notes AS reviewNotes, + s.id AS strCaseId + FROM m_aml_alert a + JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ? + LEFT JOIN m_client c ON c.id = a.client_id + LEFT JOIN m_appuser u ON u.id = a.reviewed_by + LEFT JOIN m_aml_str_case s ON s.alert_id = a.id + ORDER BY a.raised_on DESC + """; + + @Override + public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) { + if (journalEntryId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required", + "A journal entry must be given to refer."); + } + final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition); + if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) { + throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid", + "Disposition must be WHITELIST or ESCALATE.", disposition); + } + + final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE) + .orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing", + "The MANUAL_REFERRAL rule is not seeded; run the tenant migrations.")); + + // Marking the same entry twice would put two cases in front of the Principal Officer for + // one transaction, so the first mark stands until it is explicitly reworked. + if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked", + "This ledger entry has already been marked.", journalEntryId); + } + + final Map row = loadEntry(journalEntryId); + final Number clientIdValue = (Number) row.get("clientId"); + if (clientIdValue == null) { + throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client", + "This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId); + } + + final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue()); + final BigDecimal amount = (BigDecimal) row.get("amount"); + final String currencyCode = (String) row.get("currencyCode"); + + // transaction_id holds the journal entry id, which is what the ledger screen keys on. The + // MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a + // savings or loan transaction id. + final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode, + LocalDateTime.now()); + alertRepository.save(alert); + + Long strCaseId = null; + if (escalate) { + alert.escalate(context.authenticatedUser(), notes); + alertRepository.save(alert); + final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert)); + clientRiskEscalationService.onStrCaseEscalated(strCase); + strCaseId = strCase.getId(); + } else { + alert.whitelist(context.authenticatedUser(), notes); + alertRepository.save(alert); + } + + return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount, + currencyCode, alert.getRaisedOn(), alert.getStatus().name(), + alert.getDisposition() == null ? null : alert.getDisposition().name(), + alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(), + strCaseId); + } + + @Override + @Transactional(readOnly = true) + public List retrieveAll() { + return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"), + rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"), + rs.getBigDecimal("amount"), rs.getString("currencyCode"), + rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(), + rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"), + rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")), + MANUAL_RULE_CODE); + } + + private Map loadEntry(final Long journalEntryId) { + try { + return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId); + } catch (final EmptyResultDataAccessException e) { + throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found", + "Journal entry " + journalEntryId + " does not exist.", journalEntryId); + } + } + + /** + * TRANSFER covers everything that is neither a savings nor a loan posting. + * + * The entity type is checked first because a system-generated entry often carries entity_id without a transaction + * id; falling straight through to the transaction columns would mislabel those as transfers. + */ + private AmlTransactionType transactionTypeOf(final Map row) { + final Number entityType = (Number) row.get("entityType"); + if (entityType != null) { + if (entityType.intValue() == 2) { + return AmlTransactionType.SAVINGS; + } + if (entityType.intValue() == 1) { + return AmlTransactionType.LOAN; + } + } + if (row.get("savingsTxnId") != null) { + return AmlTransactionType.SAVINGS; + } + if (row.get("loanTxnId") != null) { + return AmlTransactionType.LOAN; + } + return AmlTransactionType.TRANSFER; + } +} 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 fbff88481..f2ba2da44 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 @@ -495,4 +495,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0443_seed_aml_manual_referral_rule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0443_seed_aml_manual_referral_rule.xml new file mode 100644 index 000000000..85d15f767 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0443_seed_aml_manual_referral_rule.xml @@ -0,0 +1,45 @@ + + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL' + + + INSERT INTO m_aml_transaction_rule (rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active) + VALUES ('MANUAL_REFERRAL', 'Marked suspicious by a user from the general ledger - no automated rule fired', 'ALL', null, 'THRESHOLD', null, null, false); + + + +