feat(aml): enforce review locking and sync STR case conclusions to linked alerts
This commit is contained in:
@@ -136,6 +136,40 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
|
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this alert is being worked by somebody other than the given officer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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) {
|
public void whitelist(AppUser reviewer, String notes) {
|
||||||
this.reviewedBy = reviewer;
|
this.reviewedBy = reviewer;
|
||||||
this.reviewedOn = LocalDateTime.now();
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
|||||||
+47
-14
@@ -51,7 +51,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AmlAlert whitelist(Long alertId, String notes) {
|
public AmlAlert whitelist(Long alertId, String notes) {
|
||||||
final AmlAlert alert = loadAlert(alertId);
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
alert.whitelist(context.authenticatedUser(), notes);
|
alert.whitelist(context.authenticatedUser(), notes);
|
||||||
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
|
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
|
||||||
return alertRepository.save(alert);
|
return alertRepository.save(alert);
|
||||||
@@ -59,18 +59,13 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AmlStrCase escalate(Long alertId, String notes) {
|
public AmlStrCase escalate(Long alertId, String notes) {
|
||||||
final AmlAlert alert = loadAlert(alertId);
|
// loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one.
|
||||||
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
|
// An STR is about the transaction, and one transaction gets one case.
|
||||||
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
alert.escalate(context.authenticatedUser(), notes);
|
alert.escalate(context.authenticatedUser(), notes);
|
||||||
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
||||||
alertRepository.save(alert);
|
alertRepository.save(alert);
|
||||||
|
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AmlStrCase openStrCase(final AmlAlert alert) {
|
private AmlStrCase openStrCase(final AmlAlert alert) {
|
||||||
@@ -92,7 +87,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe
|
|||||||
AmlStrCase strCase = null;
|
AmlStrCase strCase = null;
|
||||||
|
|
||||||
for (final Long alertId : alertIds) {
|
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
|
// 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.
|
// 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
|
@Override
|
||||||
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
|
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
|
||||||
final AmlAlert alert = loadAlert(alertId);
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
|
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
|
||||||
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
|
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
|
||||||
|
|
||||||
final Set<String> requested = Set.copyOf(ruleCodes);
|
final Set<String> requested = Set.copyOf(ruleCodes);
|
||||||
final List<AmlAlertRule> 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<AmlAlertRule> targeted = findings.stream()
|
||||||
|
.filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList();
|
||||||
if (targeted.isEmpty()) {
|
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) {
|
for (final AmlAlertRule finding : targeted) {
|
||||||
finding.decide(decision, context.authenticatedUser(), notes, true);
|
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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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) {
|
private AmlAlert loadAlert(Long alertId) {
|
||||||
return alertRepository.findById(alertId)
|
return alertRepository.findById(alertId)
|
||||||
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
|
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
|
||||||
|
|||||||
+38
-1
@@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
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.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.AmlStrCaseRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
|
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
|
||||||
|
|
||||||
private final AmlStrCaseRepository strCaseRepository;
|
private final AmlStrCaseRepository strCaseRepository;
|
||||||
|
private final AmlStrCaseAlertRepository strCaseAlertRepository;
|
||||||
|
private final AmlAlertRepository alertRepository;
|
||||||
private final AppUserRepository appUserRepository;
|
private final AppUserRepository appUserRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final StrSlaCalculator strSlaCalculator;
|
private final StrSlaCalculator strSlaCalculator;
|
||||||
@@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo
|
|||||||
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
|
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
|
||||||
: null;
|
: null;
|
||||||
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
|
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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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<AmlAlert> alertsOf(final AmlStrCase strCase) {
|
||||||
|
final List<AmlAlert> 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
|
@Override
|
||||||
|
|||||||
Reference in New Issue
Block a user