diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java index ac63dcd4d..feb6deb8f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java @@ -136,6 +136,40 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom { : (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes); } + /** + * Whether this alert is being worked by somebody other than the given officer. + * + *

+ * An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer + * reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction + * would carry two reviews, and whichever was written last would be the one on file. + */ + public boolean isHeldByAnother(final AppUser officer) { + return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null + && !this.reviewedBy.getId().equals(officer.getId()); + } + + /** + * Brings the alert into line with the Principal Officer's conclusion on the case it opened. + * + *

+ * An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is + * the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED, + * the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer + * who cleared it would have no way to see their own conclusion reflected where the alert is worked. + * + *

+ * Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a + * second officer overturning a decision. It is the case this alert was referred into, coming back with its answer. + */ + public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) { + this.reviewedBy = principalOfficer; + this.reviewedOn = LocalDateTime.now(); + this.reviewNotes = notes; + this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name(); + this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name(); + } + public void whitelist(AppUser reviewer, String notes) { this.reviewedBy = reviewer; this.reviewedOn = LocalDateTime.now(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java index 3fe7845f0..5ef1d4cf3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java @@ -51,7 +51,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe @Override public AmlAlert whitelist(Long alertId, String notes) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); alert.whitelist(context.authenticatedUser(), notes); decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true); return alertRepository.save(alert); @@ -59,18 +59,13 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe @Override public AmlStrCase escalate(Long alertId, String notes) { - final AmlAlert alert = loadAlert(alertId); - final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus()); + // loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one. + // An STR is about the transaction, and one transaction gets one case. + final AmlAlert alert = loadForDisposal(alertId); alert.escalate(context.authenticatedUser(), notes); decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true); alertRepository.save(alert); - - // An STR is about the transaction, so an alert already escalated - from the row or from a rule inside it - - // keeps the case it already has rather than opening a second one against the same transaction. - if (wasEscalated) { - return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert)); - } - return openStrCase(alert); + return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert)); } private AmlStrCase openStrCase(final AmlAlert alert) { @@ -92,7 +87,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe AmlStrCase strCase = null; for (final Long alertId : alertIds) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); // An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice // under two references, and the older case is the one an officer may already have worked on. @@ -135,14 +130,20 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe */ @Override public AmlAlert reviewRules(final Long alertId, final List ruleCodes, final String disposition, final String notes) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition); final List findings = alertRuleRepository.findByAlertId(alertId); final Set requested = Set.copyOf(ruleCodes); - final List targeted = findings.stream().filter(f -> requested.contains(f.getRuleCode())).toList(); + // A finding already decided is left alone, for the same reason the alert itself is: the decision recorded + // against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing + // only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than + // silently doing nothing. + final List targeted = findings.stream() + .filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList(); if (targeted.isEmpty()) { - throw new IllegalArgumentException("No matching rules on alert " + alertId); + throw new IllegalArgumentException( + "No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided"); } for (final AmlAlertRule finding : targeted) { finding.decide(decision, context.authenticatedUser(), notes, true); @@ -204,6 +205,38 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe } } + /** + * Loads an alert the current officer is entitled to dispose of. + * + *

+ * An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their + * own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up + * recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes + * it a rule: the screen hides the buttons, but the endpoint is what enforces it. + */ + private AmlAlert loadForDisposal(final Long alertId) { + final AmlAlert alert = loadAlert(alertId); + + // A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged + // transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second + // officer write over it would leave the file saying whatever was submitted last, with the earlier decision and + // the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately, + // through the STR case, where the change is itself recorded. + final AmlAlertStatus status = alert.getStatus(); + if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) { + throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by " + + (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername()) + + " and cannot be decided again"); + } + + final AppUser current = context.authenticatedUser(); + if (alert.isHeldByAnother(current)) { + throw new IllegalStateException("Alert " + alertId + " is being reviewed by " + + alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it"); + } + return alert; + } + private AmlAlert loadAlert(Long alertId) { return alertRepository.findById(alertId) .orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java index 98d3aa15a..9a2c03eff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java @@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service; import jakarta.persistence.EntityNotFoundException; import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +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.AmlStrCaseAlert; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion; import org.apache.fineract.useradministration.domain.AppUser; @@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional; public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService { private final AmlStrCaseRepository strCaseRepository; + private final AmlStrCaseAlertRepository strCaseAlertRepository; + private final AmlAlertRepository alertRepository; private final AppUserRepository appUserRepository; private final PlatformSecurityContext context; private final StrSlaCalculator strSlaCalculator; @@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo ? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId()) : null; strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate); - return strCaseRepository.save(strCase); + final AmlStrCase saved = strCaseRepository.save(strCase); + + // The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded + // clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have + // decided one thing and the screen an officer works from would say another. + final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS; + final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear") + + (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes); + for (final AmlAlert alert : alertsOf(saved)) { + alert.applyStrConclusion(suspicious, principalOfficer, note); + alertRepository.save(alert); + } + return saved; + } + + /** + * Every alert the case covers. + * + *

+ * Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and + * concluding it has to answer all of them rather than only the one whose id happens to sit on the case. + */ + private List alertsOf(final AmlStrCase strCase) { + final List covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream() + .map(AmlStrCaseAlert::getAlert).collect(Collectors.toList()); + // Cases opened before the link table existed carry only alert_id. + if (covered.isEmpty() && strCase.getAlert() != null) { + covered.add(strCase.getAlert()); + } + return covered; } @Override