Anti-Money Laundering (AML) Integration
Anti-Money Laundering (AML) compliance is a critical requirement for financial institutions to detect and prevent money laundering, terrorist financing, and other financial crimes. This guide covers the technical implementation of AML systems, including transaction monitoring, customer risk scoring, watchlist screening, and regulatory reporting.
Transaction Monitoring Patterns
Transaction monitoring systems analyze customer transactions in real-time or near real-time to detect suspicious patterns that may indicate money laundering, fraud, or other illicit activities.
Monitoring Architecture
This architecture processes transactions through a monitoring pipeline that applies multiple rule checks and risk scoring algorithms. Transactions that exceed risk thresholds generate alerts for compliance analyst review. The event-driven design allows real-time monitoring without blocking transaction processing.
Implementation
/**
* Service that monitors transactions for suspicious activity.
*
* Applies rule-based detection and machine learning models to identify
* patterns consistent with money laundering or financial crime.
*/
@Service
public class TransactionMonitoringService {
private final TransactionRepository transactionRepository;
private final CustomerRiskProfileService riskProfileService;
private final AMLRuleEngine ruleEngine;
private final AlertService alertService;
private final AuditService auditService;
/**
* Monitors a transaction for AML red flags.
*
* This method is called asynchronously for each transaction to avoid
* blocking customer-facing operations. Results feed into case management.
*/
@Async
@Transactional
public void monitorTransaction(UUID transactionId) {
Transaction transaction = transactionRepository.findById(transactionId)
.orElseThrow(() -> new TransactionNotFoundException(transactionId));
Customer customer = transaction.getCustomer();
// Build monitoring context with customer and transaction history
MonitoringContext context = MonitoringContext.builder()
.transaction(transaction)
.customer(customer)
.customerRiskProfile(riskProfileService.getRiskProfile(customer.getId()))
.recentTransactions(getRecentTransactions(customer.getId()))
.build();
// Apply monitoring rules
List<RuleViolation> violations = ruleEngine.evaluateTransaction(context);
// Calculate composite risk score
RiskScore riskScore = calculateRiskScore(context, violations);
// Store monitoring result
MonitoringResult result = MonitoringResult.builder()
.transactionId(transactionId)
.riskScore(riskScore.getValue())
.violations(violations)
.monitoredAt(Instant.now())
.build();
monitoringResultRepository.save(result);
// Generate alert if risk exceeds threshold
if (riskScore.getValue() >= getAlertThreshold(customer)) {
generateAlert(transaction, riskScore, violations);
}
// Audit all monitoring activity
auditService.logTransactionMonitoring(transactionId, riskScore, violations);
}
private List<Transaction> getRecentTransactions(UUID customerId) {
// Get transactions from past 30 days for pattern analysis
Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS);
return transactionRepository.findByCustomerIdAndCreatedAtAfter(
customerId,
thirtyDaysAgo
);
}
private RiskScore calculateRiskScore(MonitoringContext context, List<RuleViolation> violations) {
double baseScore = 0.0;
// Factor in customer risk profile
baseScore += context.getCustomerRiskProfile().getRiskScore() * 0.3;
// Factor in rule violations
baseScore += violations.stream()
.mapToDouble(RuleViolation::getSeverity)
.sum() * 0.5;
// Factor in transaction characteristics
baseScore += assessTransactionRisk(context.getTransaction()) * 0.2;
// Normalize to 0-100 scale
return RiskScore.of(Math.min(baseScore, 100.0));
}
private double assessTransactionRisk(Transaction transaction) {
double risk = 0.0;
// Large amounts are higher risk
if (transaction.getAmount().compareTo(new BigDecimal("10000")) > 0) {
risk += 15.0;
}
// Cross-border transactions are higher risk
if (!transaction.getOriginCountry().equals(transaction.getDestinationCountry())) {
risk += 10.0;
}
// High-risk countries
if (isHighRiskCountry(transaction.getDestinationCountry())) {
risk += 25.0;
}
// Cash transactions
if (transaction.getType() == TransactionType.CASH_DEPOSIT ||
transaction.getType() == TransactionType.CASH_WITHDRAWAL) {
risk += 10.0;
}
return risk;
}
private void generateAlert(Transaction transaction, RiskScore riskScore, List<RuleViolation> violations) {
AMLAlert alert = AMLAlert.builder()
.id(UUID.randomUUID())
.transactionId(transaction.getId())
.customerId(transaction.getCustomer().getId())
.alertType(determineAlertType(violations))
.riskScore(riskScore.getValue())
.violations(violations)
.status(AlertStatus.OPEN)
.priority(determinePriority(riskScore))
.createdAt(Instant.now())
.build();
alertService.createAlert(alert);
// Notify compliance team for immediate review if high priority
if (alert.getPriority() == AlertPriority.HIGH) {
notificationService.notifyComplianceTeam(alert);
}
}
}
The transaction monitoring service operates asynchronously to avoid impacting transaction throughput. It builds a comprehensive context including the customer's risk profile and recent transaction history, then applies rule-based checks and calculates a composite risk score. This multi-factor approach reduces false positives while maintaining high detection rates.
Velocity Checks
Velocity checks detect unusual transaction frequency or volume that may indicate structuring (deliberately keeping transactions below reporting thresholds) or rapid movement of funds.
/**
* Rule that detects unusual transaction velocity.
*
* Monitors transaction frequency and cumulative amounts over various
* time windows to identify patterns like structuring and rapid fund movement.
*/
@Component
public class VelocityCheckRule implements AMLRule {
@Override
public List<RuleViolation> evaluate(MonitoringContext context) {
List<RuleViolation> violations = new ArrayList<>();
Transaction current = context.getTransaction();
List<Transaction> recent = context.getRecentTransactions();
// Check daily transaction count
long todayCount = recent.stream()
.filter(t -> isToday(t.getCreatedAt()))
.count();
if (todayCount > 10) {
violations.add(RuleViolation.builder()
.ruleId("VELOCITY_001")
.ruleName("Excessive Daily Transaction Count")
.severity(15.0)
.description(String.format(
"Customer has made %d transactions today, exceeding normal pattern",
todayCount
))
.build());
}
// Check for structuring (multiple transactions just under reporting threshold)
BigDecimal reportingThreshold = new BigDecimal("10000");
List<Transaction> nearThreshold = recent.stream()
.filter(t -> isLast24Hours(t.getCreatedAt()))
.filter(t -> {
BigDecimal amount = t.getAmount();
return amount.compareTo(reportingThreshold.multiply(new BigDecimal("0.9"))) > 0
&& amount.compareTo(reportingThreshold) < 0;
})
.toList();
if (nearThreshold.size() >= 3) {
BigDecimal totalAmount = nearThreshold.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
violations.add(RuleViolation.builder()
.ruleId("VELOCITY_002")
.ruleName("Potential Structuring")
.severity(40.0)
.description(String.format(
"Detected %d transactions just below $10,000 threshold totaling $%s in 24 hours",
nearThreshold.size(),
totalAmount
))
.build());
}
// Check cumulative amount over time windows
violations.addAll(checkCumulativeVelocity(context));
// Check rapid succession of transactions
violations.addAll(checkRapidSuccession(recent));
return violations;
}
private List<RuleViolation> checkCumulativeVelocity(MonitoringContext context) {
List<RuleViolation> violations = new ArrayList<>();
List<Transaction> recent = context.getRecentTransactions();
CustomerRiskProfile profile = context.getCustomerRiskProfile();
// Calculate cumulative amounts over different windows
Map<Duration, BigDecimal> cumulativeAmounts = Map.of(
Duration.ofHours(24), sumTransactionsInWindow(recent, Duration.ofHours(24)),
Duration.ofDays(7), sumTransactionsInWindow(recent, Duration.ofDays(7)),
Duration.ofDays(30), sumTransactionsInWindow(recent, Duration.ofDays(30))
);
// Compare to customer's normal behavior
cumulativeAmounts.forEach((window, amount) -> {
BigDecimal normalAmount = profile.getAverageVolumeForWindow(window);
BigDecimal threshold = normalAmount.multiply(new BigDecimal("3")); // 3x normal
if (amount.compareTo(threshold) > 0) {
violations.add(RuleViolation.builder()
.ruleId("VELOCITY_003")
.ruleName("Unusual Cumulative Volume")
.severity(20.0)
.description(String.format(
"Cumulative transaction volume of $%s in %s exceeds 3x normal behavior ($%s)",
amount,
formatDuration(window),
normalAmount
))
.build());
}
});
return violations;
}
private List<RuleViolation> checkRapidSuccession(List<Transaction> transactions) {
List<RuleViolation> violations = new ArrayList<>();
// Sort by timestamp
List<Transaction> sorted = transactions.stream()
.sorted(Comparator.comparing(Transaction::getCreatedAt))
.toList();
// Check for rapid succession (multiple transactions within minutes)
for (int i = 1; i < sorted.size(); i++) {
Instant prev = sorted.get(i - 1).getCreatedAt();
Instant curr = sorted.get(i).getCreatedAt();
long minutesBetween = Duration.between(prev, curr).toMinutes();
if (minutesBetween < 5) {
violations.add(RuleViolation.builder()
.ruleId("VELOCITY_004")
.ruleName("Rapid Transaction Succession")
.severity(10.0)
.description(String.format(
"Multiple transactions within %d minutes",
minutesBetween
))
.build());
}
}
return violations;
}
private BigDecimal sumTransactionsInWindow(List<Transaction> transactions, Duration window) {
Instant cutoff = Instant.now().minus(window);
return transactions.stream()
.filter(t -> t.getCreatedAt().isAfter(cutoff))
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
This velocity rule detects multiple suspicious patterns: high transaction frequency, structuring attempts (multiple transactions just below reporting thresholds), unusual cumulative volumes compared to the customer's normal behavior, and rapid succession of transactions. The rule compares current behavior to established baselines, reducing false positives from customers with legitimately high transaction volumes.
Geographic Anomaly Detection
/**
* Rule that detects suspicious geographic patterns.
*
* Monitors for transactions in high-risk jurisdictions, unusual
* geographic patterns, and impossible travel scenarios.
*/
@Component
public class GeographicAnomalyRule implements AMLRule {
private final HighRiskCountryService highRiskCountryService;
@Override
public List<RuleViolation> evaluate(MonitoringContext context) {
List<RuleViolation> violations = new ArrayList<>();
Transaction current = context.getTransaction();
Customer customer = context.getCustomer();
List<Transaction> recent = context.getRecentTransactions();
// Check for high-risk jurisdictions
if (isHighRiskJurisdiction(current.getDestinationCountry())) {
violations.add(RuleViolation.builder()
.ruleId("GEO_001")
.ruleName("High-Risk Jurisdiction")
.severity(30.0)
.description(String.format(
"Transaction to high-risk jurisdiction: %s",
current.getDestinationCountry()
))
.details(Map.of(
"country", current.getDestinationCountry(),
"riskCategory", highRiskCountryService.getRiskCategory(current.getDestinationCountry())
))
.build());
}
// Check for impossible travel (transactions from different locations too close in time)
violations.addAll(checkImpossibleTravel(current, recent));
// Check for sudden geographic pattern changes
violations.addAll(checkGeographicPatternChange(customer, recent));
return violations;
}
private boolean isHighRiskJurisdiction(String countryCode) {
// Check FATF list of high-risk and non-cooperative jurisdictions
// This list should be updated regularly from authoritative sources
return highRiskCountryService.isHighRisk(countryCode);
}
private List<RuleViolation> checkImpossibleTravel(Transaction current, List<Transaction> recent) {
List<RuleViolation> violations = new ArrayList<>();
// Get the most recent transaction before current
Optional<Transaction> previous = recent.stream()
.filter(t -> t.getCreatedAt().isBefore(current.getCreatedAt()))
.max(Comparator.comparing(Transaction::getCreatedAt));
if (previous.isEmpty()) {
return violations;
}
Transaction prev = previous.get();
// Calculate distance between transaction locations
double distanceKm = calculateDistance(
prev.getOriginLatitude(), prev.getOriginLongitude(),
current.getOriginLatitude(), current.getOriginLongitude()
);
// Calculate time between transactions
long hoursBetween = Duration.between(prev.getCreatedAt(), current.getCreatedAt()).toHours();
// Average speed required to travel this distance
double requiredSpeedKph = distanceKm / Math.max(hoursBetween, 1);
// Flag if required speed exceeds reasonable travel (e.g., 900 km/h for flights)
if (requiredSpeedKph > 900 && distanceKm > 500) {
violations.add(RuleViolation.builder()
.ruleId("GEO_002")
.ruleName("Impossible Travel")
.severity(25.0)
.description(String.format(
"Transactions from locations %.0f km apart within %d hours (required speed: %.0f km/h)",
distanceKm, hoursBetween, requiredSpeedKph
))
.build());
}
return violations;
}
private List<RuleViolation> checkGeographicPatternChange(Customer customer, List<Transaction> recent) {
List<RuleViolation> violations = new ArrayList<>();
// Get customer's historical geographic patterns
Set<String> historicalCountries = customer.getHistoricalTransactionCountries();
// Get countries in recent transactions
Set<String> recentCountries = recent.stream()
.map(Transaction::getDestinationCountry)
.filter(country -> !historicalCountries.contains(country))
.collect(Collectors.toSet());
// Flag if customer suddenly starts transacting with new countries
if (recentCountries.size() >= 3) {
violations.add(RuleViolation.builder()
.ruleId("GEO_003")
.ruleName("Sudden Geographic Pattern Change")
.severity(15.0)
.description(String.format(
"Customer transacting with %d new countries: %s",
recentCountries.size(),
String.join(", ", recentCountries)
))
.build());
}
return violations;
}
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
// Haversine formula for great-circle distance
final double R = 6371; // Earth radius in km
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
}
Geographic monitoring detects patterns like transactions to sanctioned countries, impossible travel scenarios (card used in different countries within an impossible timeframe), and sudden changes in geographic behavior. The impossible travel detection is particularly valuable for identifying compromised payment credentials being used by fraudsters in different locations.
Suspicious Activity Reporting (SAR)
When transaction monitoring identifies suspicious activity, compliance analysts must investigate and potentially file a Suspicious Activity Report (SAR) with financial intelligence units like FinCEN in the United States.
SAR Workflow
Implementation
/**
* Service managing Suspicious Activity Report (SAR) lifecycle.
*
* Handles SAR drafting, review, approval, and filing with regulators.
* Maintains strict confidentiality - SARs must not be disclosed to subjects.
*/
@Service
public class SARService {
private final AMLAlertRepository alertRepository;
private final SARRepository sarRepository;
private final RegulatoryFilingService filingService;
private final AuditService auditService;
/**
* Initiates SAR filing process from an AML alert.
*
* Creates a draft SAR for compliance analyst to complete with
* investigation findings and supporting evidence.
*/
public SAR initiateSAR(UUID alertId, UUID analystId) {
AMLAlert alert = alertRepository.findById(alertId)
.orElseThrow(() -> new AlertNotFoundException(alertId));
// Verify alert hasn't already resulted in a SAR
if (sarRepository.existsByAlertId(alertId)) {
throw new IllegalStateException("SAR already exists for this alert");
}
SAR sar = SAR.builder()
.id(UUID.randomUUID())
.alertId(alertId)
.customerId(alert.getCustomerId())
.filingInstitution(getInstitutionDetails())
.status(SARStatus.DRAFT)
.createdBy(analystId)
.createdAt(Instant.now())
// Regulatory filing deadline: 30 days from detection
.filingDeadline(Instant.now().plus(30, ChronoUnit.DAYS))
.build();
sarRepository.save(sar);
// Audit SAR initiation
auditService.logSARInitiated(sar.getId(), alertId, analystId);
return sar;
}
/**
* Updates SAR with investigation findings.
*
* Analysts add narrative description of suspicious activity,
* supporting transactions, and subject information.
*/
@Transactional
public void updateSAR(UUID sarId, SARUpdate update) {
SAR sar = sarRepository.findById(sarId)
.orElseThrow(() -> new SARNotFoundException(sarId));
if (sar.getStatus() != SARStatus.DRAFT) {
throw new IllegalStateException("Can only update draft SARs");
}
// Update SAR content
sar.setSuspiciousActivityNarrative(update.getNarrative());
sar.setSubjectInformation(update.getSubjectInfo());
sar.setSupportingTransactions(update.getTransactions());
sar.setAmountInvolved(calculateTotalAmount(update.getTransactions()));
sar.setActivityType(update.getActivityType());
sar.setUpdatedAt(Instant.now());
sarRepository.save(sar);
auditService.logSARUpdated(sarId, update);
}
/**
* Submits SAR for manager review.
*
* Once analyst completes investigation, manager must review
* and approve before filing with regulator.
*/
public void submitForReview(UUID sarId) {
SAR sar = sarRepository.findById(sarId)
.orElseThrow(() -> new SARNotFoundException(sarId));
// Validate SAR is complete
validateSARComplete(sar);
sar.setStatus(SARStatus.UNDER_REVIEW);
sar.setSubmittedForReviewAt(Instant.now());
sarRepository.save(sar);
// Notify compliance manager
notificationService.notifySARReviewRequired(sar);
auditService.logSARSubmittedForReview(sarId);
}
/**
* Manager approves SAR for regulatory filing.
*/
@Transactional
public void approveSAR(UUID sarId, UUID managerId, String approvalNotes) {
SAR sar = sarRepository.findById(sarId)
.orElseThrow(() -> new SARNotFoundException(sarId));
if (sar.getStatus() != SARStatus.UNDER_REVIEW) {
throw new IllegalStateException("SAR must be under review to approve");
}
sar.setStatus(SARStatus.APPROVED);
sar.setApprovedBy(managerId);
sar.setApprovedAt(Instant.now());
sar.setApprovalNotes(approvalNotes);
sarRepository.save(sar);
// Automatically file with regulator after approval
fileWithRegulator(sar);
auditService.logSARApproved(sarId, managerId);
}
/**
* Files SAR with regulatory authority.
*
* Submits SAR through BSA E-Filing System (FinCEN) or equivalent.
* Maintains strict confidentiality - filing must not alert subject.
*/
private void fileWithRegulator(SAR sar) {
// Build regulatory filing format (FinCEN SAR XML format)
RegulatoryFiling filing = buildRegulatoryFiling(sar);
// Submit to FinCEN or appropriate Financial Intelligence Unit
FilingResponse response = filingService.submitSAR(filing);
// Record filing confirmation
sar.setStatus(SARStatus.FILED);
sar.setFiledAt(Instant.now());
sar.setRegulatoryConfirmationNumber(response.getConfirmationNumber());
sarRepository.save(sar);
// CRITICAL: Do NOT notify customer or subject of SAR filing
// This would be a criminal offense (tipping off)
auditService.logSARFiled(sar.getId(), response.getConfirmationNumber());
}
/**
* Builds regulatory filing in required format.
*
* Formats SAR data according to FinCEN specifications (XML format).
*/
private RegulatoryFiling buildRegulatoryFiling(SAR sar) {
Customer subject = customerRepository.findById(sar.getCustomerId())
.orElseThrow();
return RegulatoryFiling.builder()
.filingType(FilingType.SAR)
.filingInstitution(sar.getFilingInstitution())
// Subject information (individual or entity)
.subjectType(determineSubjectType(subject))
.subjectName(subject.getFullName())
.subjectTIN(subject.getTaxIdNumber())
.subjectAddress(subject.getAddress())
.subjectDateOfBirth(subject.getDateOfBirth())
.subjectOccupation(subject.getOccupation())
// Suspicious activity details
.suspiciousActivityType(sar.getActivityType())
.activityNarrative(sar.getSuspiciousActivityNarrative())
.activityDateRange(determineActivityDateRange(sar))
.amountInvolved(sar.getAmountInvolved())
// Supporting transactions
.transactions(mapToRegulatoryFormat(sar.getSupportingTransactions()))
// Filing metadata
.filingDate(Instant.now())
.priorReportIndicator(hasPriorSARs(sar.getCustomerId()))
.build();
}
private void validateSARComplete(SAR sar) {
List<String> errors = new ArrayList<>();
if (sar.getSuspiciousActivityNarrative() == null || sar.getSuspiciousActivityNarrative().isBlank()) {
errors.add("Suspicious activity narrative is required");
}
if (sar.getSubjectInformation() == null) {
errors.add("Subject information is required");
}
if (sar.getSupportingTransactions() == null || sar.getSupportingTransactions().isEmpty()) {
errors.add("At least one supporting transaction is required");
}
if (sar.getActivityType() == null) {
errors.add("Activity type must be specified");
}
if (!errors.isEmpty()) {
throw new ValidationException("SAR is incomplete: " + String.join(", ", errors));
}
}
/**
* Checks if customer has been subject of previous SARs.
*
* Regulatory filings require indicating if this is a continuing
* activity report related to prior SARs.
*/
private boolean hasPriorSARs(UUID customerId) {
return sarRepository.countByCustomerIdAndStatusIn(
customerId,
List.of(SARStatus.FILED)
) > 0;
}
}
/**
* Entity representing a Suspicious Activity Report.
*/
@Entity
@Table(name = "suspicious_activity_reports", schema = "compliance")
public class SAR {
@Id
private UUID id;
private UUID alertId;
private UUID customerId;
@Enumerated(EnumType.STRING)
private SARStatus status;
// Activity details
@Enumerated(EnumType.STRING)
private SuspiciousActivityType activityType;
@Column(length = 10000)
private String suspiciousActivityNarrative;
@Type(JsonType.class)
@Column(columnDefinition = "jsonb")
private SubjectInformation subjectInformation;
@OneToMany(mappedBy = "sar")
private List<SARTransaction> supportingTransactions;
private BigDecimal amountInvolved;
// Workflow tracking
private UUID createdBy;
private Instant createdAt;
private Instant submittedForReviewAt;
private UUID approvedBy;
private Instant approvedAt;
private String approvalNotes;
private Instant filedAt;
private String regulatoryConfirmationNumber;
private Instant filingDeadline;
// Institution details
@Type(JsonType.class)
@Column(columnDefinition = "jsonb")
private FilingInstitution filingInstitution;
}
public enum SARStatus {
DRAFT, // Being prepared by analyst
UNDER_REVIEW, // Submitted to manager for review
APPROVED, // Approved by manager, ready to file
FILED, // Filed with regulatory authority
REJECTED // Manager rejected, needs revision
}
public enum SuspiciousActivityType {
STRUCTURING, // Breaking up transactions to avoid reporting
SUSPICIOUS_WIRE_ACTIVITY, // Unusual wire transfer patterns
TERRORIST_FINANCING, // Potential terrorism-related activity
MONEY_LAUNDERING, // General money laundering indicators
FRAUD, // Fraudulent activity
IDENTITY_THEFT, // Identity theft indicators
ACCOUNT_TAKEOVER, // Compromised account activity
TRADE_BASED_MONEY_LAUNDERING, // TBML schemes
CYBERCRIME, // Cyber-related criminal activity
OTHER // Other suspicious activity
}
The SAR service implements a rigorous workflow with draft, review, approval, and filing stages. The multi-level review ensures quality and reduces false positives before regulatory filing. Critically, the system maintains strict confidentiality - the subject of a SAR must never be notified of the filing, as this could constitute "tipping off," which is a criminal offense in most jurisdictions.
KYC (Know Your Customer) Processes
KYC is the process of verifying customer identities and assessing their risk profiles. Robust KYC is the foundation of effective AML compliance.
KYC Workflow
Implementation
/**
* Service orchestrating KYC verification process.
*
* Verifies customer identity through multiple data sources and risk checks
* before approving account opening.
*/
@Service
public class KYCService {
private final CustomerRepository customerRepository;
private final IdentityVerificationService identityVerification;
private final DocumentVerificationService documentVerification;
private final WatchlistScreeningService watchlistScreening;
private final RiskScoringService riskScoring;
private final AuditService auditService;
/**
* Performs comprehensive KYC verification for new customer.
*
* Orchestrates multiple verification steps and aggregates results
* to make approval decision.
*/
@Transactional
public KYCResult performKYC(UUID customerId, KYCSubmission submission) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
// Step 1: Verify identity documents
DocumentVerificationResult docResult = documentVerification.verify(
submission.getDocuments()
);
// Step 2: Verify identity against authoritative data sources
IdentityVerificationResult idResult = identityVerification.verify(
submission.getPersonalInfo()
);
// Step 3: Screen against watchlists and sanctions lists
WatchlistScreeningResult screeningResult = watchlistScreening.screen(
customer.getFullName(),
customer.getDateOfBirth(),
customer.getAddress()
);
// Step 4: Calculate risk score
RiskProfile riskProfile = riskScoring.calculateCustomerRisk(
customer,
submission,
screeningResult
);
// Aggregate results
KYCResult result = KYCResult.builder()
.customerId(customerId)
.documentVerification(docResult)
.identityVerification(idResult)
.watchlistScreening(screeningResult)
.riskProfile(riskProfile)
.timestamp(Instant.now())
.build();
// Make approval decision
KYCDecision decision = makeKYCDecision(result);
result.setDecision(decision);
// Store KYC result
kycResultRepository.save(result);
// Update customer status based on decision
updateCustomerStatus(customer, decision);
// Audit KYC completion
auditService.logKYCCompleted(customerId, result);
return result;
}
private KYCDecision makeKYCDecision(KYCResult result) {
// Auto-reject if on watchlist
if (result.getWatchlistScreening().hasMatches()) {
return KYCDecision.rejected("Watchlist match");
}
// Auto-reject if document verification failed
if (!result.getDocumentVerification().isPassed()) {
return KYCDecision.rejected("Document verification failed");
}
// Auto-reject if identity verification failed
if (!result.getIdentityVerification().isPassed()) {
return KYCDecision.rejected("Identity verification failed");
}
// High-risk customers require manual review
if (result.getRiskProfile().getRiskLevel() == RiskLevel.HIGH) {
return KYCDecision.manualReview("High-risk profile");
}
// Medium-risk customers require enhanced due diligence
if (result.getRiskProfile().getRiskLevel() == RiskLevel.MEDIUM) {
return KYCDecision.enhancedDueDiligence("Medium-risk profile");
}
// Low-risk customers can be auto-approved
return KYCDecision.approved("All checks passed");
}
private void updateCustomerStatus(Customer customer, KYCDecision decision) {
switch (decision.getOutcome()) {
case APPROVED -> {
customer.setStatus(CustomerStatus.ACTIVE);
customer.setKYCCompletedAt(Instant.now());
customer.setKYCNextRefreshDate(calculateKYCRefreshDate(customer));
}
case ENHANCED_DUE_DILIGENCE -> {
customer.setStatus(CustomerStatus.PENDING_EDD);
customer.setRequiredEDDActions(determineEDDRequirements(customer));
}
case MANUAL_REVIEW -> {
customer.setStatus(CustomerStatus.PENDING_REVIEW);
}
case REJECTED -> {
customer.setStatus(CustomerStatus.REJECTED);
customer.setRejectionReason(decision.getReason());
}
}
customerRepository.save(customer);
}
/**
* Calculates when KYC refresh is required.
*
* Higher-risk customers require more frequent KYC updates.
*/
private LocalDate calculateKYCRefreshDate(Customer customer) {
RiskLevel risk = customer.getRiskProfile().getRiskLevel();
return LocalDate.now().plus(switch (risk) {
case HIGH -> 12; // High-risk: annual refresh
case MEDIUM -> 24; // Medium-risk: biennial refresh
case LOW -> 36; // Low-risk: every 3 years
}, ChronoUnit.MONTHS);
}
}
/**
* Service integrating with third-party identity verification providers.
*
* Verifies customer-provided information against authoritative data sources
* like credit bureaus, government databases, and utility records.
*/
@Service
public class IdentityVerificationService {
private final IdentityVerificationClient verificationClient;
/**
* Verifies identity using knowledge-based authentication (KBA) and data matching.
*
* Checks if provided information matches records in authoritative databases.
*/
public IdentityVerificationResult verify(PersonalInformation info) {
try {
// Call third-party verification service (e.g., Lexis Nexis, Experian)
VerificationRequest request = VerificationRequest.builder()
.firstName(info.getFirstName())
.lastName(info.getLastName())
.dateOfBirth(info.getDateOfBirth())
.ssn(info.getSocialSecurityNumber())
.address(info.getAddress())
.phoneNumber(info.getPhoneNumber())
.build();
VerificationResponse response = verificationClient.verify(request);
return IdentityVerificationResult.builder()
.passed(response.getConfidenceScore() >= 70) // Threshold varies by risk tolerance
.confidenceScore(response.getConfidenceScore())
.verifiedElements(response.getMatchedElements())
.failedElements(response.getMismatchedElements())
.providerResponse(response)
.timestamp(Instant.now())
.build();
} catch (Exception e) {
// Log error but don't fail KYC completely
logger.error("Identity verification failed", e);
return IdentityVerificationResult.builder()
.passed(false)
.error("Verification service unavailable")
.timestamp(Instant.now())
.build();
}
}
/**
* Performs document verification using OCR and liveness detection.
*
* Verifies government-issued ID documents are authentic and match the applicant.
*/
public DocumentVerificationResult verifyDocuments(List<DocumentSubmission> documents) {
List<DocumentVerificationResult> results = documents.stream()
.map(this::verifyDocument)
.toList();
boolean allPassed = results.stream().allMatch(DocumentVerificationResult::isPassed);
return DocumentVerificationResult.builder()
.passed(allPassed)
.individualResults(results)
.timestamp(Instant.now())
.build();
}
private DocumentVerificationResult verifyDocument(DocumentSubmission submission) {
// Extract data from document using OCR
OCRResult ocrResult = performOCR(submission.getImage());
// Verify document authenticity (security features, tampering detection)
AuthenticityCheck authenticityCheck = checkAuthenticity(submission.getImage());
// If selfie provided, perform face matching
FaceMatchResult faceMatch = submission.getSelfieImage() != null
? performFaceMatch(submission.getImage(), submission.getSelfieImage())
: null;
boolean passed = ocrResult.isSuccessful() &&
authenticityCheck.isAuthentic() &&
(faceMatch == null || faceMatch.isMatch());
return DocumentVerificationResult.builder()
.documentType(submission.getDocumentType())
.passed(passed)
.extractedData(ocrResult.getData())
.authenticityScore(authenticityCheck.getConfidenceScore())
.faceMatchScore(faceMatch != null ? faceMatch.getConfidenceScore() : null)
.timestamp(Instant.now())
.build();
}
}
The KYC service orchestrates multiple verification steps: document verification ensures submitted IDs are authentic, identity verification confirms the information matches authoritative records, and watchlist screening checks for sanctions or PEP (Politically Exposed Person) status. The risk-based approach applies different approval thresholds based on the customer's risk profile - low-risk customers can be auto-approved, while high-risk customers require manual review.
Customer Risk Scoring
Customer risk scoring assigns a risk level to each customer based on various factors. This risk score determines the level of monitoring and due diligence required.
Risk Factors
/**
* Service that calculates customer risk scores.
*
* Applies a risk model considering multiple factors to assign
* an overall risk rating to each customer.
*/
@Service
public class CustomerRiskScoringService {
/**
* Calculates comprehensive risk score for a customer.
*
* Considers multiple dimensions:
* - Geographic risk (country of residence, transaction locations)
* - Occupation and income source
* - Business relationship purpose
* - Transaction patterns and expected activity
* - PEP (Politically Exposed Person) status
* - Adverse media or negative news
*/
public RiskProfile calculateCustomerRisk(Customer customer, KYCSubmission submission,
WatchlistScreeningResult screening) {
double riskScore = 0.0;
List<RiskFactor> contributingFactors = new ArrayList<>();
// Geographic risk (weight: 25%)
double geoRisk = assessGeographicRisk(customer);
riskScore += geoRisk * 0.25;
if (geoRisk > 50) {
contributingFactors.add(new RiskFactor("Geographic Risk", geoRisk,
"Customer located in or transacting with high-risk jurisdiction"));
}
// Occupation and income source (weight: 20%)
double occupationRisk = assessOccupationRisk(customer);
riskScore += occupationRisk * 0.20;
if (occupationRisk > 50) {
contributingFactors.add(new RiskFactor("Occupation Risk", occupationRisk,
"High-risk occupation or unclear income source"));
}
// Business relationship purpose (weight: 15%)
double purposeRisk = assessBusinessPurposeRisk(submission);
riskScore += purposeRisk * 0.15;
if (purposeRisk > 50) {
contributingFactors.add(new RiskFactor("Business Purpose Risk", purposeRisk,
"Unclear or high-risk business relationship purpose"));
}
// Expected transaction behavior (weight: 20%)
double transactionRisk = assessExpectedTransactionRisk(submission);
riskScore += transactionRisk * 0.20;
if (transactionRisk > 50) {
contributingFactors.add(new RiskFactor("Transaction Risk", transactionRisk,
"Expected transaction volume or patterns indicate elevated risk"));
}
// PEP status (weight: 15%)
double pepRisk = assessPEPRisk(screening);
riskScore += pepRisk * 0.15;
if (pepRisk > 50) {
contributingFactors.add(new RiskFactor("PEP Risk", pepRisk,
"Politically Exposed Person or close associate"));
}
// Adverse media (weight: 5%)
double mediaRisk = assessAdverseMedia(screening);
riskScore += mediaRisk * 0.05;
if (mediaRisk > 50) {
contributingFactors.add(new RiskFactor("Adverse Media", mediaRisk,
"Negative news or adverse media mentions"));
}
// Determine risk level based on score
RiskLevel riskLevel = determineRiskLevel(riskScore);
return RiskProfile.builder()
.customerId(customer.getId())
.riskScore(riskScore)
.riskLevel(riskLevel)
.contributingFactors(contributingFactors)
.calculatedAt(Instant.now())
.nextReviewDate(calculateNextReviewDate(riskLevel))
.build();
}
private double assessGeographicRisk(Customer customer) {
double risk = 0.0;
// Country of residence risk
CountryRiskRating countryRisk = countryRiskService.getRiskRating(
customer.getAddress().getCountryCode()
);
risk += switch (countryRisk) {
case HIGH_RISK -> 80.0; // FATF blacklist, sanctions
case MEDIUM_RISK -> 50.0; // FATF greylist
case LOW_RISK -> 20.0; // Low-risk jurisdictions
};
// Border region risk
if (customer.getAddress().isNearInternationalBorder()) {
risk += 10.0; // Higher cross-border smuggling risk
}
return Math.min(risk, 100.0);
}
private double assessOccupationRisk(Customer customer) {
String occupation = customer.getOccupation();
IndustryRiskRating industryRisk = industryRiskService.getRiskRating(occupation);
return switch (industryRisk) {
case HIGH_RISK -> 80.0; // Cash-intensive businesses, casinos, crypto exchanges
case MEDIUM_RISK -> 50.0; // Real estate, jewelry, art dealers
case LOW_RISK -> 20.0; // Salaried employees, established businesses
};
}
private double assessBusinessPurposeRisk(KYCSubmission submission) {
String purpose = submission.getBusinessRelationshipPurpose();
// Vague or unclear purpose is higher risk
if (purpose == null || purpose.length() < 20) {
return 70.0;
}
// Check for high-risk keywords
List<String> highRiskKeywords = List.of(
"investment", "trading", "broker", "intermediary",
"consulting", "international", "offshore"
);
long matches = highRiskKeywords.stream()
.filter(keyword -> purpose.toLowerCase().contains(keyword))
.count();
return Math.min(30.0 + (matches * 15.0), 100.0);
}
private double assessExpectedTransactionRisk(KYCSubmission submission) {
double risk = 0.0;
// Very high expected transaction volume
BigDecimal monthlyVolume = submission.getExpectedMonthlyVolume();
if (monthlyVolume.compareTo(new BigDecimal("100000")) > 0) {
risk += 30.0;
}
// International transaction expectations
if (submission.expectsInternationalTransactions()) {
risk += 20.0;
}
// Cash transaction expectations
if (submission.expectsCashTransactions()) {
risk += 25.0;
}
return risk;
}
private double assessPEPRisk(WatchlistScreeningResult screening) {
if (!screening.hasPEPMatches()) {
return 10.0; // Everyone has slight PEP risk
}
PEPMatch match = screening.getPEPMatch();
return switch (match.getPEPLevel()) {
case DIRECT_PEP -> 90.0; // The person is a PEP
case FAMILY_MEMBER -> 70.0; // Close family member of PEP
case CLOSE_ASSOCIATE -> 60.0; // Known associate of PEP
case FORMER_PEP -> 40.0; // Was a PEP, no longer in position
};
}
private double assessAdverseMedia(WatchlistScreeningResult screening) {
if (!screening.hasAdverseMedia()) {
return 0.0;
}
// Score based on recency and severity of adverse media
AdverseMediaMatch media = screening.getAdverseMedia();
double risk = 30.0; // Base risk for any adverse media
// Recent adverse media is higher risk
if (media.isRecentlyPublished()) {
risk += 20.0;
}
// Serious allegations (fraud, money laundering) are highest risk
if (media.containsSeriousAllegations()) {
risk += 30.0;
}
return Math.min(risk, 100.0);
}
private RiskLevel determineRiskLevel(double riskScore) {
if (riskScore >= 70) return RiskLevel.HIGH;
if (riskScore >= 40) return RiskLevel.MEDIUM;
return RiskLevel.LOW;
}
private LocalDate calculateNextReviewDate(RiskLevel riskLevel) {
return LocalDate.now().plus(switch (riskLevel) {
case HIGH -> 6; // High-risk: review every 6 months
case MEDIUM -> 12; // Medium-risk: annual review
case LOW -> 24; // Low-risk: biennial review
}, ChronoUnit.MONTHS);
}
}
The risk scoring model uses a weighted approach where different risk factors contribute to the overall score. Geographic risk considers both the customer's location and their expected transaction patterns. Occupation risk accounts for cash-intensive businesses that are commonly associated with money laundering. PEP status significantly increases risk because politically exposed persons face higher corruption temptation and scrutiny. The risk level determines monitoring intensity and review frequency.
Transaction Limits and Thresholds
Regulatory requirements and risk management principles necessitate transaction limits based on customer risk profiles and suspicious activity patterns.
Dynamic Limits
/**
* Service managing transaction limits based on risk profiles.
*
* Enforces regulatory limits and applies risk-based controls
* to restrict high-risk transaction patterns.
*/
@Service
public class TransactionLimitService {
/**
* Determines if a transaction is within allowed limits.
*
* Checks both regulatory thresholds and risk-based limits
* specific to the customer's profile.
*/
public LimitCheckResult checkTransactionLimits(Transaction transaction) {
Customer customer = transaction.getCustomer();
RiskProfile riskProfile = customer.getRiskProfile();
List<LimitViolation> violations = new ArrayList<>();
// Check regulatory reporting thresholds
violations.addAll(checkRegulatoryThresholds(transaction));
// Check daily transaction limits
violations.addAll(checkDailyLimits(customer, transaction));
// Check monthly limits
violations.addAll(checkMonthlyLimits(customer, transaction));
// Check velocity limits (frequency-based)
violations.addAll(checkVelocityLimits(customer, transaction));
// Check risk-based limits
violations.addAll(checkRiskBasedLimits(customer, riskProfile, transaction));
boolean withinLimits = violations.isEmpty();
return LimitCheckResult.builder()
.withinLimits(withinLimits)
.violations(violations)
.action(determineAction(violations))
.build();
}
private List<LimitViolation> checkRegulatoryThresholds(Transaction transaction) {
List<LimitViolation> violations = new ArrayList<>();
// Currency Transaction Report (CTR) threshold: $10,000
if (transaction.getAmount().compareTo(new BigDecimal("10000")) > 0 &&
transaction.getType().isCashTransaction()) {
violations.add(LimitViolation.builder()
.type(ViolationType.CTR_THRESHOLD)
.severity(LimitSeverity.INFORMATIONAL)
.message("Transaction exceeds $10,000 CTR threshold - CTR filing required")
.requiresAction(LimitAction.FILE_CTR)
.build());
}
return violations;
}
private List<LimitViolation> checkDailyLimits(Customer customer, Transaction transaction) {
List<LimitViolation> violations = new ArrayList<>();
// Calculate today's transaction volume
BigDecimal todayVolume = transactionRepository
.sumAmountByCustomerIdAndDate(customer.getId(), LocalDate.now());
// Daily limit based on customer risk
BigDecimal dailyLimit = getDailyLimit(customer.getRiskProfile().getRiskLevel());
if (todayVolume.add(transaction.getAmount()).compareTo(dailyLimit) > 0) {
violations.add(LimitViolation.builder()
.type(ViolationType.DAILY_LIMIT)
.severity(LimitSeverity.BLOCKING)
.message(String.format(
"Daily transaction limit of $%s exceeded (current: $%s, transaction: $%s)",
dailyLimit, todayVolume, transaction.getAmount()
))
.requiresAction(LimitAction.BLOCK_TRANSACTION)
.build());
}
return violations;
}
private List<LimitViolation> checkVelocityLimits(Customer customer, Transaction transaction) {
List<LimitViolation> violations = new ArrayList<>();
// Count transactions in last hour
long recentCount = transactionRepository.countByCustomerIdAndTimeRange(
customer.getId(),
Instant.now().minus(1, ChronoUnit.HOURS),
Instant.now()
);
// Velocity limit: max 5 transactions per hour
if (recentCount >= 5) {
violations.add(LimitViolation.builder()
.type(ViolationType.VELOCITY_LIMIT)
.severity(LimitSeverity.BLOCKING)
.message(String.format(
"Transaction velocity limit exceeded: %d transactions in last hour (limit: 5)",
recentCount
))
.requiresAction(LimitAction.BLOCK_TRANSACTION)
.build());
}
return violations;
}
private List<LimitViolation> checkRiskBasedLimits(Customer customer, RiskProfile riskProfile,
Transaction transaction) {
List<LimitViolation> violations = new ArrayList<>();
// High-risk customers have stricter limits
if (riskProfile.getRiskLevel() == RiskLevel.HIGH) {
// Restrict international transactions for high-risk customers
if (!transaction.getDestinationCountry().equals(customer.getAddress().getCountryCode())) {
violations.add(LimitViolation.builder()
.type(ViolationType.HIGH_RISK_RESTRICTION)
.severity(LimitSeverity.REQUIRE_APPROVAL)
.message("International transactions require approval for high-risk customers")
.requiresAction(LimitAction.REQUIRE_APPROVAL)
.build());
}
// Limit transaction amounts for high-risk customers
if (transaction.getAmount().compareTo(new BigDecimal("5000")) > 0) {
violations.add(LimitViolation.builder()
.type(ViolationType.HIGH_RISK_AMOUNT_LIMIT)
.severity(LimitSeverity.REQUIRE_APPROVAL)
.message("Transactions over $5,000 require approval for high-risk customers")
.requiresAction(LimitAction.REQUIRE_APPROVAL)
.build());
}
}
return violations;
}
private BigDecimal getDailyLimit(RiskLevel riskLevel) {
return switch (riskLevel) {
case HIGH -> new BigDecimal("5000"); // $5,000 daily limit
case MEDIUM -> new BigDecimal("25000"); // $25,000 daily limit
case LOW -> new BigDecimal("100000"); // $100,000 daily limit
};
}
private LimitAction determineAction(List<LimitViolation> violations) {
// If any violation is blocking, block the transaction
if (violations.stream().anyMatch(v -> v.getSeverity() == LimitSeverity.BLOCKING)) {
return LimitAction.BLOCK_TRANSACTION;
}
// If any violation requires approval, require approval
if (violations.stream().anyMatch(v -> v.getSeverity() == LimitSeverity.REQUIRE_APPROVAL)) {
return LimitAction.REQUIRE_APPROVAL;
}
// Informational violations just trigger regulatory filings
return LimitAction.PROCEED_WITH_FILING;
}
}
Transaction limits serve multiple purposes: regulatory compliance (CTR filings for cash transactions over $10,000), fraud prevention (velocity limits), and risk management (stricter limits for high-risk customers). The dynamic limit system adjusts based on customer risk profiles - high-risk customers face lower limits and more restrictions. This risk-based approach balances user experience for low-risk customers with enhanced controls for high-risk scenarios.
Watchlist Screening
Watchlist screening checks customers and transactions against sanctions lists, PEP databases, and adverse media to identify individuals or entities requiring heightened scrutiny or prohibited from conducting business.
Screening Process
/**
* Service for screening customers and transactions against watchlists.
*
* Integrates with multiple watchlist sources including OFAC, EU sanctions,
* UN sanctions, PEP lists, and adverse media databases.
*/
@Service
public class WatchlistScreeningService {
private final OFACClient ofacClient;
private final EUSanctionsClient euSanctionsClient;
private final UNSanctionsClient unSanctionsClient;
private final PEPDatabaseClient pepClient;
private final AdverseMediaClient mediaClient;
private final FuzzyMatchingService fuzzyMatcher;
/**
* Performs comprehensive watchlist screening.
*
* Screens against multiple lists and applies fuzzy matching to catch
* variations in names, transliterations, and aliases.
*/
public WatchlistScreeningResult screen(String name, LocalDate dateOfBirth, Address address) {
List<WatchlistMatch> matches = new ArrayList<>();
// Screen against OFAC Specially Designated Nationals (SDN) list
matches.addAll(screenOFAC(name, dateOfBirth, address));
// Screen against EU sanctions list
matches.addAll(screenEUSanctions(name, dateOfBirth, address));
// Screen against UN sanctions list
matches.addAll(screenUNSanctions(name, dateOfBirth, address));
// Screen against PEP (Politically Exposed Persons) database
matches.addAll(screenPEP(name, dateOfBirth, address));
// Screen against adverse media
matches.addAll(screenAdverseMedia(name, dateOfBirth));
// Filter false positives based on confidence scores and secondary identifiers
List<WatchlistMatch> confirmedMatches = filterFalsePositives(matches, dateOfBirth, address);
return WatchlistScreeningResult.builder()
.screenedName(name)
.screenedDate(LocalDate.now())
.allMatches(matches)
.confirmedMatches(confirmedMatches)
.requiresReview(requiresManualReview(confirmedMatches))
.cleared(confirmedMatches.isEmpty())
.build();
}
private List<WatchlistMatch> screenOFAC(String name, LocalDate dateOfBirth, Address address) {
// Get OFAC SDN list entries
List<OFACEntry> ofacEntries = ofacClient.getSDNList();
return ofacEntries.stream()
.map(entry -> fuzzyMatcher.match(name, entry.getName()))
.filter(match -> match.getConfidenceScore() >= 0.85) // 85% threshold
.map(match -> buildWatchlistMatch(match, WatchlistSource.OFAC, dateOfBirth, address))
.toList();
}
private List<WatchlistMatch> screenPEP(String name, LocalDate dateOfBirth, Address address) {
// Query PEP database
List<PEPEntry> pepEntries = pepClient.search(name);
return pepEntries.stream()
.map(entry -> {
FuzzyMatchResult nameMatch = fuzzyMatcher.match(name, entry.getName());
// Check date of birth match if available
boolean dobMatch = entry.getDateOfBirth() == null ||
entry.getDateOfBirth().equals(dateOfBirth);
// Check country match
boolean countryMatch = entry.getCountry() == null ||
entry.getCountry().equals(address.getCountryCode());
// Adjust confidence based on secondary identifiers
double adjustedConfidence = nameMatch.getConfidenceScore();
if (dobMatch) adjustedConfidence += 0.05;
if (countryMatch) adjustedConfidence += 0.05;
return WatchlistMatch.builder()
.source(WatchlistSource.PEP)
.matchedName(entry.getName())
.matchedEntity(entry)
.confidenceScore(adjustedConfidence)
.pepLevel(entry.getPEPLevel())
.position(entry.getPosition())
.build();
})
.filter(match -> match.getConfidenceScore() >= 0.80)
.toList();
}
private List<WatchlistMatch> screenAdverseMedia(String name, LocalDate dateOfBirth) {
// Search news and media for negative mentions
AdverseMediaSearchResult mediaResult = mediaClient.search(name, dateOfBirth);
if (!mediaResult.hasMatches()) {
return Collections.emptyList();
}
return mediaResult.getMatches().stream()
.filter(article -> article.getSeverityScore() >= 0.7) // Only serious allegations
.map(article -> WatchlistMatch.builder()
.source(WatchlistSource.ADVERSE_MEDIA)
.matchedName(name)
.confidenceScore(article.getRelevanceScore())
.adverseMediaDetails(AdverseMediaDetails.builder()
.headline(article.getHeadline())
.publicationDate(article.getPublicationDate())
.source(article.getSource())
.categories(article.getCategories()) // e.g., fraud, corruption, money laundering
.summary(article.getSummary())
.build())
.build())
.toList();
}
private List<WatchlistMatch> filterFalsePositives(List<WatchlistMatch> matches,
LocalDate dateOfBirth, Address address) {
return matches.stream()
.filter(match -> {
// If we have strong secondary identifier matches, trust the name match
if (match.getMatchedEntity() != null) {
boolean dobMatch = dateOfBirth != null &&
dateOfBirth.equals(match.getMatchedEntity().getDateOfBirth());
boolean countryMatch = address != null &&
address.getCountryCode().equals(match.getMatchedEntity().getCountry());
// If DOB and country match, this is almost certainly a true positive
if (dobMatch && countryMatch) {
return true;
}
// If only one secondary identifier matches but name confidence is high, still flag
if ((dobMatch || countryMatch) && match.getConfidenceScore() >= 0.90) {
return true;
}
}
// For high-confidence name matches with no secondary identifiers, still flag for review
return match.getConfidenceScore() >= 0.95;
})
.toList();
}
private boolean requiresManualReview(List<WatchlistMatch> confirmedMatches) {
// Any sanctions match requires manual review
if (confirmedMatches.stream().anyMatch(m ->
m.getSource() == WatchlistSource.OFAC ||
m.getSource() == WatchlistSource.EU_SANCTIONS ||
m.getSource() == WatchlistSource.UN_SANCTIONS)) {
return true;
}
// High-level PEPs require review
if (confirmedMatches.stream().anyMatch(m ->
m.getPepLevel() == PEPLevel.DIRECT_PEP ||
m.getPepLevel() == PEPLevel.FAMILY_MEMBER)) {
return true;
}
// Serious adverse media requires review
if (confirmedMatches.stream().anyMatch(m ->
m.getSource() == WatchlistSource.ADVERSE_MEDIA &&
m.getAdverseMediaDetails().containsSeriousAllegations())) {
return true;
}
return false;
}
/**
* Screens transactions in real-time.
*
* Checks both originator and beneficiary against watchlists
* before allowing transaction to proceed.
*/
public TransactionScreeningResult screenTransaction(Transaction transaction) {
// Screen originator
WatchlistScreeningResult originatorScreening = screen(
transaction.getOriginatorName(),
null, // DOB may not be available for wire transfer beneficiaries
transaction.getOriginatorAddress()
);
// Screen beneficiary
WatchlistScreeningResult beneficiaryScreening = screen(
transaction.getBeneficiaryName(),
null,
transaction.getBeneficiaryAddress()
);
boolean blocked = !originatorScreening.isCleared() || !beneficiaryScreening.isCleared();
return TransactionScreeningResult.builder()
.transactionId(transaction.getId())
.originatorScreening(originatorScreening)
.beneficiaryScreening(beneficiaryScreening)
.blocked(blocked)
.requiresReview(originatorScreening.requiresReview() || beneficiaryScreening.requiresReview())
.timestamp(Instant.now())
.build();
}
}
/**
* Service implementing fuzzy string matching for watchlist screening.
*
* Uses multiple algorithms to catch name variations, typos,
* and transliteration differences.
*/
@Service
public class FuzzyMatchingService {
public FuzzyMatchResult match(String name1, String name2) {
// Normalize names (remove accents, lowercase, trim)
String normalized1 = normalize(name1);
String normalized2 = normalize(name2);
// Calculate multiple similarity metrics
double levenshteinScore = calculateLevenshteinSimilarity(normalized1, normalized2);
double jaroWinklerScore = calculateJaroWinklerSimilarity(normalized1, normalized2);
double soundexScore = calculateSoundexSimilarity(normalized1, normalized2);
// Weighted combination of metrics
double compositeScore = (levenshteinScore * 0.4) +
(jaroWinklerScore * 0.4) +
(soundexScore * 0.2);
return FuzzyMatchResult.builder()
.name1(name1)
.name2(name2)
.confidenceScore(compositeScore)
.levenshteinScore(levenshteinScore)
.jaroWinklerScore(jaroWinklerScore)
.soundexScore(soundexScore)
.build();
}
private String normalize(String name) {
return Normalizer.normalize(name, Normalizer.Form.NFD)
.replaceAll("\\p{M}", "") // Remove accents
.toLowerCase()
.trim()
.replaceAll("\\s+", " "); // Normalize whitespace
}
private double calculateLevenshteinSimilarity(String s1, String s2) {
int distance = LevenshteinDistance.getDefaultInstance().apply(s1, s2);
int maxLength = Math.max(s1.length(), s2.length());
return 1.0 - ((double) distance / maxLength);
}
private double calculateJaroWinklerSimilarity(String s1, String s2) {
return new JaroWinklerDistance().apply(s1, s2);
}
private double calculateSoundexSimilarity(String s1, String s2) {
Soundex soundex = new Soundex();
try {
return soundex.encode(s1).equals(soundex.encode(s2)) ? 1.0 : 0.0;
} catch (Exception e) {
return 0.0;
}
}
}
Watchlist screening uses fuzzy matching to catch name variations - people may use different transliterations of their names, include or omit middle names, or have minor typos. The system combines multiple string similarity algorithms (Levenshtein distance, Jaro-Winkler, Soundex) to calculate a composite confidence score. Secondary identifiers like date of birth and country help filter false positives. Sanctions matches automatically block transactions, while PEP matches trigger enhanced due diligence. Real-time screening occurs before transaction processing completes, preventing prohibited transactions.
Regulatory Reporting Requirements
Financial institutions must file various reports with regulators to comply with AML laws. These reports must be filed within specific timeframes and contain detailed information.
Report Types
/**
* Service handling regulatory report filing.
*
* Manages preparation and submission of Currency Transaction Reports (CTR),
* Suspicious Activity Reports (SAR), and other required AML filings.
*/
@Service
public class RegulatoryReportingService {
/**
* Files Currency Transaction Report (CTR) for cash transactions over $10,000.
*
* Required by Bank Secrecy Act for all cash transactions exceeding threshold.
*/
public void fileCTR(Transaction transaction) {
if (!transaction.getType().isCashTransaction()) {
throw new IllegalArgumentException("CTR only applies to cash transactions");
}
if (transaction.getAmount().compareTo(new BigDecimal("10000")) <= 0) {
throw new IllegalArgumentException("CTR threshold not exceeded");
}
Customer customer = transaction.getCustomer();
CurrencyTransactionReport ctr = CurrencyTransactionReport.builder()
.transactionDate(transaction.getCreatedAt())
.transactionAmount(transaction.getAmount())
.transactionType(transaction.getType())
// Individual or entity conducting transaction
.conductingParty(CustomerInfo.builder()
.name(customer.getFullName())
.taxId(customer.getTaxIdNumber())
.dateOfBirth(customer.getDateOfBirth())
.address(customer.getAddress())
.identification(customer.getIdentificationDocuments())
.occupation(customer.getOccupation())
.build())
// Financial institution information
.filingInstitution(getInstitutionInfo())
// Transaction details
.accountNumbers(transaction.getAffectedAccounts())
.build();
// Submit to FinCEN
FilingResponse response = finCENClient.submitCTR(ctr);
// Record filing
recordRegulatoryFiling(RegulatoryFilingType.CTR, transaction.getId(), response);
}
/**
* Aggregates related transactions and files aggregated CTR.
*
* Multiple related transactions totaling over $10,000 must be reported
* even if each individual transaction is under the threshold.
*/
@Scheduled(cron = "0 0 2 * * *") // Run daily at 2 AM
public void detectAndFileAggregatedCTRs() {
LocalDate yesterday = LocalDate.now().minusDays(1);
// Find customers with multiple cash transactions yesterday
List<Customer> customersWithMultipleCashTransactions = transactionRepository
.findCustomersWithMultipleCashTransactionsOnDate(yesterday);
for (Customer customer : customersWithMultipleCashTransactions) {
List<Transaction> cashTransactions = transactionRepository
.findCashTransactionsByCustomerAndDate(customer.getId(), yesterday);
BigDecimal totalAmount = cashTransactions.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// File aggregated CTR if total exceeds threshold
if (totalAmount.compareTo(new BigDecimal("10000")) > 0) {
fileAggregatedCTR(customer, cashTransactions, totalAmount);
}
}
}
private void recordRegulatoryFiling(RegulatoryFilingType type, UUID transactionId,
FilingResponse response) {
RegulatoryFiling filing = RegulatoryFiling.builder()
.id(UUID.randomUUID())
.filingType(type)
.transactionId(transactionId)
.filedAt(Instant.now())
.confirmationNumber(response.getConfirmationNumber())
.status(FilingStatus.FILED)
.build();
filingRepository.save(filing);
auditService.logRegulatoryFiling(filing);
}
}
public enum RegulatoryFilingType {
CTR, // Currency Transaction Report
SAR, // Suspicious Activity Report
FBAR, // Foreign Bank Account Report
CMIR, // Currency and Monetary Instrument Report
DOEP, // Designation of Exempt Person
REGISTRATION_314A // 314(a) Information Sharing
}
Regulatory reporting is time-sensitive and detail-critical. CTRs must capture complete information about cash transactions over $10,000, including customer identification and transaction details. The automated daily job detects aggregated transactions - multiple related transactions that individually fall below the threshold but collectively exceed it (a structuring red flag). All filings receive confirmation numbers from the regulatory system, which are stored for audit purposes. Failures in regulatory reporting can result in significant penalties, so the system includes retry logic and alerting for filing failures.
Audit Trails and Documentation
Every AML-related decision, investigation, and action must be documented to demonstrate compliance during regulatory examinations.
/**
* Service maintaining comprehensive AML audit trail.
*
* Logs all AML-related activities in an immutable audit log
* that regulators can review during examinations.
*/
@Service
public class AMLAuditService {
private final AuditLogRepository auditLogRepository;
public void logTransactionMonitoring(UUID transactionId, RiskScore riskScore,
List<RuleViolation> violations) {
AuditLog log = AuditLog.builder()
.eventType(AMLEventType.TRANSACTION_MONITORING)
.transactionId(transactionId)
.timestamp(Instant.now())
.details(Map.of(
"riskScore", riskScore.getValue(),
"violations", violations.stream()
.map(RuleViolation::getRuleName)
.collect(Collectors.toList())
))
.build();
auditLogRepository.save(log);
}
public void logAlertReview(UUID alertId, UUID reviewerId, AlertDisposition disposition,
String rationale) {
AuditLog log = AuditLog.builder()
.eventType(AMLEventType.ALERT_REVIEW)
.alertId(alertId)
.userId(reviewerId)
.timestamp(Instant.now())
.details(Map.of(
"disposition", disposition.name(),
"rationale", rationale
))
.build();
auditLogRepository.save(log);
}
public void logSARFiled(UUID sarId, String confirmationNumber) {
AuditLog log = AuditLog.builder()
.eventType(AMLEventType.SAR_FILED)
.sarId(sarId)
.timestamp(Instant.now())
.details(Map.of(
"confirmationNumber", confirmationNumber,
"filingAuthority", "FinCEN"
))
.build();
auditLogRepository.save(log);
}
public void logWatchlistScreening(UUID customerId, WatchlistScreeningResult result) {
AuditLog log = AuditLog.builder()
.eventType(AMLEventType.WATCHLIST_SCREENING)
.customerId(customerId)
.timestamp(Instant.now())
.details(Map.of(
"screened", true,
"matchCount", result.getConfirmedMatches().size(),
"cleared", result.isCleared(),
"requiresReview", result.requiresReview()
))
.build();
auditLogRepository.save(log);
}
}
The audit trail captures every AML-related action with timestamp, actor, and rationale. This documentation is crucial during regulatory examinations to demonstrate that the institution has effective AML controls and investigates suspicious activity appropriately. Audit logs are immutable and retained for at least 5 years as required by most AML regulations.
System Integration Patterns
AML systems must integrate with core banking systems, external data providers, and regulatory filing systems.
The event-driven architecture allows real-time transaction monitoring without impacting core banking performance. External integrations provide watchlist data, identity verification, and adverse media screening. The case management system provides a workflow for analysts to investigate alerts and file reports. All components log to a centralized audit system for regulatory compliance and reporting.
Related Guidelines
- For data privacy and consent management, see Data Privacy
- For API security and authentication patterns, see Security Overview
- For event-driven architecture patterns, see Event-Driven Architecture
- For transaction ledger integrity, see Transaction Ledgers
- For observability and audit logging, see Observability - Logging
- For Spring Boot testing strategies, see Spring Boot Testing