feat(sanctions): expose the loaded list's data-currency date on every screening result

Screening only ever told a caller whether a name matched and when the
check ran — not which dated snapshot of the UN Security Council list it
ran against. Added SanctionsEntryRepository.findListLastLoadedOn()
(MAX(createdDate) over active entries) and threaded it through
SanctionsScreeningResult as listDataAsOf, so the frontend can show "List
Data As Of: <date>" alongside "Screened On".
This commit is contained in:
2026-07-30 10:33:33 +05:30
parent 7c15fbb0c7
commit 02541eed6c
3 changed files with 24 additions and 8 deletions
@@ -18,7 +18,9 @@
*/ */
package org.apache.fineract.portfolio.sanctions.domain; package org.apache.fineract.portfolio.sanctions.domain;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -40,4 +42,11 @@ public interface SanctionsEntryRepository extends JpaRepository<SanctionsEntry,
void deleteAllByActiveTrue(); void deleteAllByActiveTrue();
long countByActiveTrue(); long countByActiveTrue();
/**
* Timestamp the active sanctions list was last loaded into the database — i.e. the data
* currency shown to callers so they know which list snapshot a screening ran against.
*/
@Query("SELECT MAX(s.createdDate) FROM SanctionsEntry s WHERE s.active = true")
Optional<OffsetDateTime> findListLastLoadedOn();
} }
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.sanctions.data; package org.apache.fineract.portfolio.sanctions.data;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
/** /**
@@ -32,10 +33,11 @@ public final class SanctionsScreeningResult {
private final String message; private final String message;
private final int matchCount; private final int matchCount;
private final List<SanctionsMatchData> matches; private final List<SanctionsMatchData> matches;
private final OffsetDateTime listDataAsOf;
private SanctionsScreeningResult(final Long screeningLogId, final String screenedName, final boolean matchFound, private SanctionsScreeningResult(final Long screeningLogId, final String screenedName, final boolean matchFound,
final String status, final String message, final int matchCount, final String status, final String message, final int matchCount,
final List<SanctionsMatchData> matches) { final List<SanctionsMatchData> matches, final OffsetDateTime listDataAsOf) {
this.screeningLogId = screeningLogId; this.screeningLogId = screeningLogId;
this.screenedName = screenedName; this.screenedName = screenedName;
this.matchFound = matchFound; this.matchFound = matchFound;
@@ -43,20 +45,22 @@ public final class SanctionsScreeningResult {
this.message = message; this.message = message;
this.matchCount = matchCount; this.matchCount = matchCount;
this.matches = matches; this.matches = matches;
this.listDataAsOf = listDataAsOf;
} }
public static SanctionsScreeningResult matched(final Long screeningLogId, final String screenedName, public static SanctionsScreeningResult matched(final Long screeningLogId, final String screenedName,
final List<SanctionsMatchData> matches) { final List<SanctionsMatchData> matches, final OffsetDateTime listDataAsOf) {
return new SanctionsScreeningResult(screeningLogId, screenedName, true, "FLAGGED", return new SanctionsScreeningResult(screeningLogId, screenedName, true, "FLAGGED",
"Potential match found in UN Security Council consolidated sanctions list. " "Potential match found in UN Security Council consolidated sanctions list. "
+ "Manual review required before proceeding.", + "Manual review required before proceeding.",
matches.size(), matches); matches.size(), matches, listDataAsOf);
} }
public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName) { public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName,
final OffsetDateTime listDataAsOf) {
return new SanctionsScreeningResult(screeningLogId, screenedName, false, "CLEAR", return new SanctionsScreeningResult(screeningLogId, screenedName, false, "CLEAR",
"No match found in UN Security Council consolidated sanctions list.", "No match found in UN Security Council consolidated sanctions list.",
0, List.of()); 0, List.of(), listDataAsOf);
} }
public Long getScreeningLogId() { return screeningLogId; } public Long getScreeningLogId() { return screeningLogId; }
@@ -66,4 +70,5 @@ public final class SanctionsScreeningResult {
public String getMessage() { return message; } public String getMessage() { return message; }
public int getMatchCount() { return matchCount; } public int getMatchCount() { return matchCount; }
public List<SanctionsMatchData> getMatches() { return matches; } public List<SanctionsMatchData> getMatches() { return matches; }
public OffsetDateTime getListDataAsOf() { return listDataAsOf; }
} }
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.sanctions.service; package org.apache.fineract.portfolio.sanctions.service;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -54,18 +55,19 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
final Long clientId, final String ipAddress) { final Long clientId, final String ipAddress) {
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
final Long logId = saveLog(name, "CLEAR", 0, null, null, source, clientId, ipAddress); final Long logId = saveLog(name, "CLEAR", 0, null, null, source, clientId, ipAddress);
return SanctionsScreeningResult.clear(logId, name); return SanctionsScreeningResult.clear(logId, name, sanctionsEntryRepository.findListLastLoadedOn().orElse(null));
} }
final String trimmedName = name.trim(); final String trimmedName = name.trim();
LOG.info("Screening name against sanctions list: {}", trimmedName); LOG.info("Screening name against sanctions list: {}", trimmedName);
final OffsetDateTime listDataAsOf = sanctionsEntryRepository.findListLastLoadedOn().orElse(null);
final List<SanctionsEntry> matches = sanctionsEntryRepository.searchByNameOrAlias(trimmedName); final List<SanctionsEntry> matches = sanctionsEntryRepository.searchByNameOrAlias(trimmedName);
if (matches.isEmpty()) { if (matches.isEmpty()) {
LOG.info("No sanctions match found for: {}", trimmedName); LOG.info("No sanctions match found for: {}", trimmedName);
final Long logId = saveLog(trimmedName, "CLEAR", 0, null, null, source, clientId, ipAddress); final Long logId = saveLog(trimmedName, "CLEAR", 0, null, null, source, clientId, ipAddress);
return SanctionsScreeningResult.clear(logId, trimmedName); return SanctionsScreeningResult.clear(logId, trimmedName, listDataAsOf);
} }
LOG.warn("Sanctions match found for '{}': {} potential match(es)", trimmedName, matches.size()); LOG.warn("Sanctions match found for '{}': {} potential match(es)", trimmedName, matches.size());
@@ -84,7 +86,7 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
final Long logId = saveLog(trimmedName, "FLAGGED", matches.size(), matchedEntryIds, matchedNames, source, clientId, ipAddress); final Long logId = saveLog(trimmedName, "FLAGGED", matches.size(), matchedEntryIds, matchedNames, source, clientId, ipAddress);
return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList); return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList, listDataAsOf);
} }
@Override @Override