feat(aml): add API endpoints and services for manual journal entry referrals
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
This commit is contained in:
+6
@@ -35,4 +35,10 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
long countByClientId(Long clientId);
|
||||
|
||||
/**
|
||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
*/
|
||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||
}
|
||||
|
||||
+8
@@ -19,9 +19,17 @@
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||
|
||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
||||
|
||||
/**
|
||||
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
|
||||
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
|
||||
* by hand from the ledger.
|
||||
*/
|
||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||
}
|
||||
|
||||
+28
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/alerts")
|
||||
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlAlertReadPlatformService readPlatformService;
|
||||
private final AmlAlertWritePlatformService writePlatformService;
|
||||
private final JournalEntryReferralService referralService;
|
||||
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
||||
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
|
||||
.orElse(null);
|
||||
return this.toApiJsonSerializer.serialize(updated);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("manual-referrals")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List ledger entries marked by a user",
|
||||
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
|
||||
public String retrieveManualReferrals() {
|
||||
this.context.authenticatedUser();
|
||||
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("manual-referral")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mark a ledger entry cleared or suspicious",
|
||||
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
|
||||
public String referJournalEntry(final ManualReferralRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
|
||||
request.getNotes());
|
||||
return this.referralJsonSerializer.serialize(referral);
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.api;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
|
||||
*
|
||||
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
|
||||
* no report) or ESCALATE (raises an STR case for the Principal Officer).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ManualReferralRequest {
|
||||
|
||||
private Long journalEntryId;
|
||||
private String disposition;
|
||||
private String notes;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
|
||||
*
|
||||
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
|
||||
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
|
||||
*/
|
||||
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
|
||||
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
|
||||
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
|
||||
/**
|
||||
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
|
||||
*
|
||||
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
|
||||
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
|
||||
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
|
||||
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
|
||||
*/
|
||||
public interface JournalEntryReferralService {
|
||||
|
||||
/** disposition: WHITELIST | ESCALATE. */
|
||||
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
|
||||
|
||||
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
|
||||
List<ManualReferralData> retrieveAll();
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
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.AmlStrCaseRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
|
||||
|
||||
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
|
||||
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlStrCaseRepository strCaseRepository;
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||
|
||||
/**
|
||||
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
|
||||
* posted against.
|
||||
*
|
||||
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
|
||||
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
|
||||
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
|
||||
* a savings-type entity may be either a savings or a current account, which live in separate tables.
|
||||
*
|
||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||
*/
|
||||
private static final String RESOLVE_SQL = """
|
||||
SELECT je.amount AS amount,
|
||||
je.currency_code AS currencyCode,
|
||||
je.savings_transaction_id AS savingsTxnId,
|
||||
je.loan_transaction_id AS loanTxnId,
|
||||
je.entity_type_enum AS entityType,
|
||||
COALESCE(
|
||||
esa.client_id,
|
||||
eca.client_id,
|
||||
eloan.client_id,
|
||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||
sa.client_id,
|
||||
l.client_id,
|
||||
ct.client_id
|
||||
) AS clientId
|
||||
FROM acc_gl_journal_entry je
|
||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
|
||||
LEFT JOIN m_loan l ON l.id = lt.loan_id
|
||||
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
|
||||
WHERE je.id = ?
|
||||
""";
|
||||
|
||||
private static final String LIST_SQL = """
|
||||
SELECT a.id AS alertId,
|
||||
a.transaction_id AS journalEntryId,
|
||||
a.client_id AS clientId,
|
||||
c.display_name AS clientDisplayName,
|
||||
a.amount AS amount,
|
||||
a.currency_code AS currencyCode,
|
||||
a.raised_on AS raisedOn,
|
||||
a.status AS status,
|
||||
a.disposition AS disposition,
|
||||
u.username AS reviewedByUsername,
|
||||
a.review_notes AS reviewNotes,
|
||||
s.id AS strCaseId
|
||||
FROM m_aml_alert a
|
||||
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
|
||||
LEFT JOIN m_client c ON c.id = a.client_id
|
||||
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
|
||||
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
|
||||
ORDER BY a.raised_on DESC
|
||||
""";
|
||||
|
||||
@Override
|
||||
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
|
||||
if (journalEntryId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
|
||||
"A journal entry must be given to refer.");
|
||||
}
|
||||
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
|
||||
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
|
||||
"Disposition must be WHITELIST or ESCALATE.", disposition);
|
||||
}
|
||||
|
||||
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
|
||||
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
|
||||
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
|
||||
|
||||
// Marking the same entry twice would put two cases in front of the Principal Officer for
|
||||
// one transaction, so the first mark stands until it is explicitly reworked.
|
||||
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
|
||||
"This ledger entry has already been marked.", journalEntryId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = loadEntry(journalEntryId);
|
||||
final Number clientIdValue = (Number) row.get("clientId");
|
||||
if (clientIdValue == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
|
||||
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
|
||||
}
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
final String currencyCode = (String) row.get("currencyCode");
|
||||
|
||||
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
|
||||
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
|
||||
// savings or loan transaction id.
|
||||
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
|
||||
LocalDateTime.now());
|
||||
alertRepository.save(alert);
|
||||
|
||||
Long strCaseId = null;
|
||||
if (escalate) {
|
||||
alert.escalate(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
strCaseId = strCase.getId();
|
||||
} else {
|
||||
alert.whitelist(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
}
|
||||
|
||||
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
|
||||
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getDisposition() == null ? null : alert.getDisposition().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
|
||||
strCaseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ManualReferralData> retrieveAll() {
|
||||
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
|
||||
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
|
||||
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
|
||||
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
|
||||
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
|
||||
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
|
||||
MANUAL_RULE_CODE);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadEntry(final Long journalEntryId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
|
||||
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TRANSFER covers everything that is neither a savings nor a loan posting.
|
||||
*
|
||||
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
|
||||
* id; falling straight through to the transaction columns would mislabel those as transfers.
|
||||
*/
|
||||
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
|
||||
final Number entityType = (Number) row.get("entityType");
|
||||
if (entityType != null) {
|
||||
if (entityType.intValue() == 2) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (entityType.intValue() == 1) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
}
|
||||
if (row.get("savingsTxnId") != null) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (row.get("loanTxnId") != null) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
return AmlTransactionType.TRANSFER;
|
||||
}
|
||||
}
|
||||
@@ -495,4 +495,5 @@
|
||||
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
m_aml_alert.rule_id is NOT NULL, so an alert a user raises by hand from the ledger still
|
||||
needs a rule to hang off. MANUAL_REFERRAL is that rule.
|
||||
|
||||
It is seeded with is_active = false ON PURPOSE. AmlRuleEvaluationServiceImpl selects
|
||||
candidates with findByActiveTrueAndAppliesTo, so an active ALL-scoped rule with a null
|
||||
threshold would be evaluated against every live transaction. Inactive keeps it out of the
|
||||
engine entirely; the referral service loads it by rule_code instead.
|
||||
-->
|
||||
<changeSet author="fineract" id="0443-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_transaction_rule (rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
|
||||
VALUES ('MANUAL_REFERRAL', 'Marked suspicious by a user from the general ledger - no automated rule fired', 'ALL', null, 'THRESHOLD', null, null, false);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user