feat(aml): enforce review locking and sync STR case conclusions to linked alerts

This commit is contained in:
2026-09-18 10:29:17 +05:30
parent 521d61d6b7
commit c1e6e036d0
3 changed files with 119 additions and 15 deletions
@@ -136,6 +136,40 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
: (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) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
@@ -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<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 List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
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()) {
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.
*
* <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) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
@@ -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.
*
* <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