Compare commits
2
Commits
4c0512f154
...
934c11bf87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934c11bf87 | ||
|
|
c1e6e036d0 |
@@ -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();
|
||||
|
||||
+47
-14
@@ -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"));
|
||||
|
||||
+38
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user