Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Resolved two conflicts: - changelog-tenant.xml: both branches independently added Liquibase parts numbered 0438-0446 (dev: Share Withdrawal/Closure governance + KYC exception fields; demo: AML rule tiers/branch seeding/email tracking/family-member children table). Renumbered demo's incoming set to 0447-0455 (files renamed + internal changeSet ids updated to match) so both feature sets keep their own migrations intact. - 0434_seed_aml_transaction_rules.xml: same seed data on both sides, differing only in the disambiguating SQL predicate. Kept dev's `AND aml_check_status IS NOT NULL` (deterministically targets the intended m_client_risk_rule row, per the file's own comment) over demo's `LIMIT 1` (arbitrary, could pick the wrong row). Also dropped two JVM crash-dump log files that got staged by mistake.
This commit is contained in:
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||
// Client edit approval email open-tracking pixel — hit by the client's email client
|
||||
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
||||
// path is the only capability credential; the endpoint always returns a GIF so
|
||||
// token validity is not leaked via different HTTP responses.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
|
||||
+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 {}
|
||||
+32
-3
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,6 +28,8 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -35,8 +38,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
|
||||
private static final int MEDIUM_RISK = 2;
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -56,20 +63,42 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmount() == null) {
|
||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||
if (threshold == null) {
|
||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
||||
return false;
|
||||
}
|
||||
switch (rule.getRuleType()) {
|
||||
case THRESHOLD:
|
||||
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
return context.getAmount().compareTo(threshold) >= 0;
|
||||
case STRUCTURING:
|
||||
case VELOCITY:
|
||||
return context.getWindowAggregateAmount() != null
|
||||
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
|
||||
* clients.
|
||||
*/
|
||||
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
|
||||
.orElse(null);
|
||||
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
|
||||
return rule.getThresholdAmountHighRisk();
|
||||
}
|
||||
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
|
||||
return rule.getThresholdAmountMediumRisk();
|
||||
}
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+84
-3
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/clients")
|
||||
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
|
||||
|
||||
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
|
||||
* whether the token matched, to avoid leaking token validity via response differences. */
|
||||
private static final byte[] TRANSPARENT_GIF = {
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
|
||||
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
|
||||
};
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a pending Client details edit",
|
||||
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
||||
+ "The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@Context final HttpServletRequest httpRequest) {
|
||||
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||
}
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
||||
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
||||
// reachable by the client's email client without needing a configured server.url property.
|
||||
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
|
||||
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
|
||||
: ":" + httpRequest.getServerPort());
|
||||
|
||||
// Tenant ID from the Fineract-specific header (same header the frontend sends).
|
||||
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
|
||||
tenantId != null ? tenantId : "default");
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
// ── Email preview (authenticated) ─────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
||||
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
||||
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
||||
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
||||
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
||||
if (preview == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("edit/pixel/{token}")
|
||||
@Produces("image/gif")
|
||||
@Operation(summary = "Email open tracking pixel",
|
||||
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
||||
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
||||
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
||||
public Response trackEmailOpen(@PathParam("token") final String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
try {
|
||||
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
||||
if (pendingEditOpt.isPresent()) {
|
||||
final var pendingEdit = pendingEditOpt.get();
|
||||
pendingEdit.markEmailOpened();
|
||||
pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
// Never fail a pixel request — email clients don't handle errors well.
|
||||
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
||||
}
|
||||
}
|
||||
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.header("Pragma", "no-cache")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||
nameVerifiedParamName, screeningLogIdParamName,
|
||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
||||
"oldMobileVerificationExceptionDocumentId"));
|
||||
|
||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||
|
||||
+5
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
|
||||
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
|
||||
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
||||
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
||||
|
||||
+14
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
|
||||
private final String checkerName;
|
||||
private final OffsetDateTime checkedDate;
|
||||
private final String rejectReason;
|
||||
|
||||
// ── Email tracking fields ─────────────────────────────────────────────────
|
||||
|
||||
/** Whether a confirmation email was successfully sent to the client after approval. */
|
||||
private final Boolean emailSent;
|
||||
|
||||
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
|
||||
private final OffsetDateTime emailSentAt;
|
||||
|
||||
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
|
||||
private final OffsetDateTime emailOpenedAt;
|
||||
|
||||
// emailSubject and emailBody are deliberately NOT included in the list DTO —
|
||||
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
|
||||
}
|
||||
|
||||
+53
-1
@@ -46,6 +46,20 @@ public final class KycRiskRuleData implements Serializable {
|
||||
private final String willfulDefaulter;
|
||||
private final String ecgcSalFlag;
|
||||
private final String dormantFlag;
|
||||
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
|
||||
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
|
||||
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
|
||||
private final BigDecimal txnThresholdAmount;
|
||||
private final String txnRuleType;
|
||||
private final Integer txnWindowDays;
|
||||
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
|
||||
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
|
||||
private final BigDecimal txnThresholdAmountMediumRisk;
|
||||
private final BigDecimal txnThresholdAmountHighRisk;
|
||||
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
|
||||
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||
private final BigDecimal txnMinLimitAmount;
|
||||
private final BigDecimal txnMaxLimitAmount;
|
||||
|
||||
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||
@@ -55,7 +69,10 @@ public final class KycRiskRuleData implements Serializable {
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
||||
final Integer riskCategory, final String reason, final Integer orderPosition,
|
||||
final String amlCheckStatus, final String bannedEntityMatch,
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) {
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
||||
this.id = id;
|
||||
this.ruleName = ruleName;
|
||||
this.legalFormId = legalFormId;
|
||||
@@ -77,6 +94,13 @@ public final class KycRiskRuleData implements Serializable {
|
||||
this.willfulDefaulter = willfulDefaulter;
|
||||
this.ecgcSalFlag = ecgcSalFlag;
|
||||
this.dormantFlag = dormantFlag;
|
||||
this.txnThresholdAmount = txnThresholdAmount;
|
||||
this.txnRuleType = txnRuleType;
|
||||
this.txnWindowDays = txnWindowDays;
|
||||
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
|
||||
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -162,4 +186,32 @@ public final class KycRiskRuleData implements Serializable {
|
||||
public String getDormantFlag() {
|
||||
return dormantFlag;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmount() {
|
||||
return txnThresholdAmount;
|
||||
}
|
||||
|
||||
public String getTxnRuleType() {
|
||||
return txnRuleType;
|
||||
}
|
||||
|
||||
public Integer getTxnWindowDays() {
|
||||
return txnWindowDays;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmountMediumRisk() {
|
||||
return txnThresholdAmountMediumRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmountHighRisk() {
|
||||
return txnThresholdAmountHighRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnMinLimitAmount() {
|
||||
return txnMinLimitAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnMaxLimitAmount() {
|
||||
return txnMaxLimitAmount;
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_family_member_children")
|
||||
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "family_member_id", nullable = false)
|
||||
private ClientFamilyMembers familyMember;
|
||||
|
||||
@Column(name = "full_name", nullable = false)
|
||||
private String fullName;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gender_cv_id", nullable = false)
|
||||
private CodeValue gender;
|
||||
|
||||
@Column(name = "date_of_birth", nullable = true)
|
||||
private LocalDate dateOfBirth;
|
||||
|
||||
protected ClientFamilyMemberChildren() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
this.familyMember = familyMember;
|
||||
this.fullName = fullName;
|
||||
this.gender = gender;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
|
||||
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
|
||||
}
|
||||
|
||||
public ClientFamilyMembers getFamilyMember() {
|
||||
return this.familyMember;
|
||||
}
|
||||
|
||||
public void setFamilyMember(ClientFamilyMembers familyMember) {
|
||||
this.familyMember = familyMember;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return this.fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public CodeValue getGender() {
|
||||
return this.gender;
|
||||
}
|
||||
|
||||
public void setGender(CodeValue gender) {
|
||||
this.gender = gender;
|
||||
}
|
||||
|
||||
public LocalDate getDateOfBirth() {
|
||||
return this.dateOfBirth;
|
||||
}
|
||||
|
||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
}
|
||||
+18
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "modified_on")
|
||||
private LocalDateTime modifiedOn;
|
||||
|
||||
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
|
||||
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
|
||||
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
||||
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
|
||||
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
||||
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public Long getModifiedBy() { return modifiedBy; }
|
||||
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
||||
|
||||
public java.util.List<ClientFamilyMemberChildren> getChildren() {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
|
||||
if (this.children == null) {
|
||||
this.children = new java.util.ArrayList<>();
|
||||
} else {
|
||||
this.children.clear();
|
||||
}
|
||||
if (children != null) {
|
||||
this.children.addAll(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||
private String pendingLockKey;
|
||||
|
||||
// ── Email tracking ────────────────────────────────────────────────────────
|
||||
|
||||
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
|
||||
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
|
||||
@Column(name = "email_tracking_token", length = 36, unique = true)
|
||||
private String emailTrackingToken;
|
||||
|
||||
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
|
||||
@Column(name = "email_sent", nullable = false)
|
||||
private Boolean emailSent = Boolean.FALSE;
|
||||
|
||||
/** Timestamp of the successful send. Null when emailSent is false. */
|
||||
@Column(name = "email_sent_at")
|
||||
private OffsetDateTime emailSentAt;
|
||||
|
||||
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
|
||||
* the 1×1 img tag. Null until opened. */
|
||||
@Column(name = "email_opened_at")
|
||||
private OffsetDateTime emailOpenedAt;
|
||||
|
||||
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
|
||||
* without regenerating the template. */
|
||||
@Column(name = "email_subject", length = 300)
|
||||
private String emailSubject;
|
||||
|
||||
/** Exact HTML body that was sent — stored alongside email_subject. */
|
||||
@Column(name = "email_body", columnDefinition = "TEXT")
|
||||
private String emailBody;
|
||||
|
||||
/** Exact sender address configured at the time the email was dispatched. */
|
||||
@Column(name = "email_from", length = 255)
|
||||
private String emailFrom;
|
||||
|
||||
// ── Factory / lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||
final AppUser maker) {
|
||||
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
edit.makerId = maker.getId();
|
||||
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||
edit.emailSent = Boolean.FALSE;
|
||||
return edit;
|
||||
}
|
||||
|
||||
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
this.rejectReason = reason;
|
||||
this.pendingLockKey = null;
|
||||
}
|
||||
|
||||
/** Called after a successful email send. Records the token (for pixel tracking), the exact
|
||||
* subject and body, and marks the row as sent. */
|
||||
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
|
||||
this.emailTrackingToken = token;
|
||||
this.emailSubject = subject;
|
||||
this.emailBody = body;
|
||||
this.emailFrom = fromAddress;
|
||||
this.emailSent = Boolean.TRUE;
|
||||
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
|
||||
public void markEmailOpened() {
|
||||
if (this.emailOpenedAt == null) {
|
||||
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
|
||||
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||
|
||||
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||
|
||||
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
|
||||
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
|
||||
}
|
||||
|
||||
+6
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
|
||||
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||
}
|
||||
|
||||
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
|
||||
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
|
||||
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
|
||||
return this.repository.findByEmailTrackingToken(token);
|
||||
}
|
||||
|
||||
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||
return this.repository.saveAndFlush(edit);
|
||||
}
|
||||
|
||||
+3
-1
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
|
||||
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
||||
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
||||
|
||||
public static final String CHILDREN = "children";
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
||||
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
||||
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
|
||||
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
||||
public static final String RELATION_SHIP_ID = "relationShipId";
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
|
||||
/**
|
||||
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
|
||||
* their pending profile-edit. Also records the tracking token, subject and body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
|
||||
* dialog can reproduce it later without regenerating the template.
|
||||
*
|
||||
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
|
||||
* that a mail-server outage never rolls back an already-committed approval.
|
||||
*/
|
||||
public interface ClientEditEmailService {
|
||||
|
||||
/**
|
||||
* Attempts to send a confirmation email to the client. On success, calls
|
||||
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
|
||||
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
|
||||
* {@code pendingEdit} — the approval itself is never rolled back.
|
||||
*
|
||||
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
|
||||
* yet persisted by the caller at this point)
|
||||
* @param client the client whose details were edited; used for display name and email
|
||||
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
|
||||
* {@code https://cbs.example.com}) used to build the tracking-pixel href
|
||||
* @param tenantId the current tenant identifier, used to build the pixel URL
|
||||
*/
|
||||
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
|
||||
* Checker. Records the tracking token, exact subject and exact body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
|
||||
* the entity after this method returns).
|
||||
*
|
||||
* <p>Template selection:
|
||||
* <ul>
|
||||
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
|
||||
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
|
||||
* {@code original_json}). Lists old→new values in a table.</li>
|
||||
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
|
||||
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
|
||||
* approval transaction to roll back. The caller catches any exception from this service and logs it.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
|
||||
|
||||
private static final String BANK_NAME = "CBSNexora";
|
||||
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
|
||||
|
||||
/** Contact fields that trigger Template A when they actually change. */
|
||||
private static final String FIELD_MOBILE = "mobileNo";
|
||||
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
|
||||
private static final String FIELD_EMAIL = "emailAddress";
|
||||
|
||||
/** Human-readable labels for every field that can appear in the email body — mirrors the
|
||||
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
|
||||
private static final Map<String, String> FIELD_LABELS;
|
||||
static {
|
||||
FIELD_LABELS = new LinkedHashMap<>();
|
||||
FIELD_LABELS.put("firstname", "First Name");
|
||||
FIELD_LABELS.put("middlename", "Middle Name");
|
||||
FIELD_LABELS.put("lastname", "Last Name");
|
||||
FIELD_LABELS.put("fullname", "Full Name");
|
||||
FIELD_LABELS.put("mobileNo", "Mobile Number");
|
||||
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
|
||||
FIELD_LABELS.put("emailAddress", "Email Address");
|
||||
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
|
||||
FIELD_LABELS.put("genderId", "Gender");
|
||||
FIELD_LABELS.put("maritalStatusId", "Marital Status");
|
||||
FIELD_LABELS.put("educationLevelId", "Education Level");
|
||||
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
|
||||
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
|
||||
FIELD_LABELS.put("occupationId", "Occupation");
|
||||
FIELD_LABELS.put("religionId", "Religion");
|
||||
FIELD_LABELS.put("socialCategoryId", "Social Category");
|
||||
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
|
||||
FIELD_LABELS.put("nationalityId", "Nationality");
|
||||
FIELD_LABELS.put("officeId", "Branch / Office");
|
||||
FIELD_LABELS.put("staffId", "Relationship Manager");
|
||||
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
|
||||
FIELD_LABELS.put("legalFormId", "Legal Form");
|
||||
FIELD_LABELS.put("salutationId", "Salutation");
|
||||
}
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
|
||||
|
||||
@Override
|
||||
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
|
||||
final String baseUrl, final String tenantId) {
|
||||
|
||||
final String toEmail = client.getEmailAddress();
|
||||
if (toEmail == null || toEmail.isBlank()) {
|
||||
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
|
||||
client.getId(), pendingEdit.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final String token = UUID.randomUUID().toString();
|
||||
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
|
||||
|
||||
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
|
||||
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
|
||||
|
||||
final boolean isContactChange = detectContactChange(payload, original);
|
||||
|
||||
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
|
||||
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
|
||||
|
||||
final String subject;
|
||||
final String body;
|
||||
if (isContactChange) {
|
||||
subject = "Your contact details have been updated — " + BANK_NAME;
|
||||
body = buildContactChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
} else {
|
||||
subject = "Your profile details have been updated — " + BANK_NAME;
|
||||
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
}
|
||||
|
||||
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
|
||||
|
||||
if (sent) {
|
||||
String fromEmail = "noreply@cbsnexora.in";
|
||||
try {
|
||||
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
|
||||
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
|
||||
fromEmail = smtp.getFromEmail();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
|
||||
}
|
||||
|
||||
pendingEdit.markEmailSent(token, subject, body, fromEmail);
|
||||
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
|
||||
} else {
|
||||
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
|
||||
}
|
||||
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
|
||||
pendingEdit.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Template detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if any of the three contact fields actually changed between
|
||||
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
|
||||
* falls back to false (Template B).
|
||||
*/
|
||||
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
|
||||
if (original == null) {
|
||||
return false;
|
||||
}
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String payloadVal = stringOf(payload, field);
|
||||
final String originalVal = stringOf(original, field);
|
||||
// Only counts as a change when both sides have a value and they differ, OR when
|
||||
// payload has a non-blank value and original didn't have the key at all.
|
||||
if (payloadVal != null && !payloadVal.equals(originalVal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Template A ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildContactChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder rows = new StringBuilder();
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String newVal = stringOf(payload, field);
|
||||
final String oldVal = original != null ? stringOf(original, field) : null;
|
||||
if (newVal == null) {
|
||||
continue;
|
||||
}
|
||||
if (oldVal != null && oldVal.equals(newVal)) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(field, field);
|
||||
rows.append("<tr>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
|
||||
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
|
||||
.append(escHtml(newVal)).append("</td>")
|
||||
.append("</tr>");
|
||||
}
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
|
||||
+ "<thead><tr>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
|
||||
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Template B ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder items = new StringBuilder();
|
||||
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
|
||||
final String key = entry.getKey();
|
||||
// Skip internal/non-display keys
|
||||
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
|
||||
continue;
|
||||
}
|
||||
final String payloadVal = stringOf(payload, key);
|
||||
final String originalVal = original != null ? stringOf(original, key) : null;
|
||||
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(key, key);
|
||||
items.append("<li>").append(escHtml(label)).append("</li>");
|
||||
}
|
||||
final String listHtml = items.length() > 0
|
||||
? "<ul style='padding-left:20px;'>" + items + "</ul>"
|
||||
: "<p><em>No specific fields identified.</em></p>";
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<p>The following fields were changed:</p>"
|
||||
+ listHtml
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
|
||||
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
|
||||
// without requiring a tenant header (it accepts the token and commits in the default
|
||||
// datasource context). However we embed it as a query param so future multi-tenant
|
||||
// support is easy to add without changing the stored URL.
|
||||
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
|
||||
}
|
||||
|
||||
private JsonObject parseJson(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final JsonElement el = JsonParser.parseString(json);
|
||||
return el.isJsonObject() ? el.getAsJsonObject() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject obj, final String key) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final JsonElement el = obj.get(key);
|
||||
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
||||
}
|
||||
|
||||
private String escHtml(final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
|
||||
|
||||
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult approveEdit(Long clientId);
|
||||
/**
|
||||
* Approves the pending edit and, on success, attempts to send a confirmation email to the
|
||||
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
|
||||
* can embed an absolute tracking-pixel URL in the email body.
|
||||
*
|
||||
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
|
||||
* @param tenantId current tenant identifier
|
||||
*/
|
||||
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
|
||||
|
||||
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||
}
|
||||
|
||||
+24
-1
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
|
||||
|
||||
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||
|
||||
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final ClientWritePlatformService clientWritePlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ClientEditEmailService clientEditEmailService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult approveEdit(final Long clientId) {
|
||||
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
|
||||
final AppUser checker = this.context.authenticatedUser();
|
||||
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||
|
||||
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
||||
// used, so every bit of Client's existing validation/change-detection logic runs exactly
|
||||
// as it would for an immediate edit — just deferred until a different user approves it.
|
||||
final Client client = pendingEdit.getClient();
|
||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
pendingEdit.approve(checker);
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
// Send confirmation email — silently swallowed on any failure so the approval is never
|
||||
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
|
||||
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
|
||||
// email tracking columns. If the send fails or the client has no email, this save is
|
||||
// a no-op (email_sent remains false).
|
||||
try {
|
||||
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
|
||||
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
|
||||
} else {
|
||||
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
|
||||
pendingEdit.getId(), e);
|
||||
}
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
||||
|
||||
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
for (ClientFamilyMembersData fm : familyMembers) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return familyMembers;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
||||
|
||||
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
if (fm != null) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
|
||||
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
|
||||
"c.gender_cv_id as genderId, cv.code_value as gender " +
|
||||
"from m_family_member_children c " +
|
||||
"left join m_code_value cv on c.gender_cv_id = cv.id " +
|
||||
"where c.family_member_id = ?";
|
||||
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
|
||||
.id(rs.getLong("id"))
|
||||
.fullName(rs.getString("fullName"))
|
||||
.genderId(JdbcSupport.getLong(rs, "genderId"))
|
||||
.gender(rs.getString("gender"))
|
||||
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
|
||||
.build();
|
||||
}, familyMemberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+41
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
|
||||
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
|
||||
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
if (childrenArray != null) {
|
||||
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
|
||||
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
|
||||
for (JsonElement childElement : childrenArray) {
|
||||
if (childElement.isJsonNull()) continue;
|
||||
JsonObject childObj = childElement.getAsJsonObject();
|
||||
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
|
||||
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
|
||||
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
|
||||
LocalDate dob = null;
|
||||
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
|
||||
try {
|
||||
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
|
||||
} catch (DateTimeParseException e) {
|
||||
LOG.warn("Could not parse dateOfBirth for child", e);
|
||||
}
|
||||
}
|
||||
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
|
||||
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
|
||||
}
|
||||
}
|
||||
}
|
||||
familyMember.setChildren(children);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateFamilyRequirementsForActivation(final Client client) {
|
||||
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
||||
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
||||
}
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
||||
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||
|
||||
if (member.has("children") && !member.get("children").isJsonNull()) {
|
||||
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
|
||||
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||
|
||||
}
|
||||
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||
clientFamilyMember.getRelationship(), familyMemberId);
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||
|
||||
+9
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
|
||||
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
|
||||
*/
|
||||
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||
Integer limit);
|
||||
|
||||
/**
|
||||
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
|
||||
* edit — only populated once the approval email has been sent. Both {@code clientId} and
|
||||
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
|
||||
* when the record does not exist.
|
||||
*/
|
||||
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
|
||||
}
|
||||
|
||||
+37
-1
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
||||
pe.submitted_date AS submittedDate,
|
||||
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
JOIN m_appuser maker ON maker.id = pe.maker_id
|
||||
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
||||
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
|
||||
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
|
||||
final boolean emailSent = rs.getBoolean("emailSent");
|
||||
return ClientPendingEditData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
.checkerName(rs.getString("checkerName")) //
|
||||
.checkedDate(checkedDate) //
|
||||
.rejectReason(rs.getString("rejectReason")) //
|
||||
.emailSent(emailSent) //
|
||||
.emailSentAt(emailSentAt) //
|
||||
.emailOpenedAt(emailOpenedAt) //
|
||||
.build();
|
||||
};
|
||||
|
||||
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
|
||||
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
|
||||
final String sql = """
|
||||
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
|
||||
c.email_address AS clientEmail
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
WHERE pe.id = ? AND pe.client_id = ?
|
||||
""";
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
||||
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("emailSubject", rs.getString("emailSubject"));
|
||||
result.put("emailBody", rs.getString("emailBody"));
|
||||
result.put("emailSent", rs.getBoolean("emailSent"));
|
||||
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
|
||||
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
|
||||
result.put("emailFrom", rs.getString("emailFrom"));
|
||||
result.put("emailTo", rs.getString("clientEmail"));
|
||||
return result;
|
||||
}, pendingEditId, clientId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -136,6 +137,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
private final CifNumberGenerator cifNumberGenerator;
|
||||
private final ClientContactOtpService clientContactOtpService;
|
||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -788,13 +790,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
* client onboarded without a mobile number is having one set for the first time) — there is no
|
||||
* existing verified channel to prove possession of in that case.
|
||||
*/
|
||||
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
|
||||
private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
|
||||
final String newValue) {
|
||||
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
||||
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
||||
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
||||
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
|
||||
|
||||
if (StringUtils.isNotBlank(oldValue)) {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
|
||||
final Integer docCount = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
|
||||
Integer.class, oldMobileExceptionDocId, clientId);
|
||||
if (docCount == null || docCount == 0) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.client.mobile.old.verification.exception.invalid.document",
|
||||
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
|
||||
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
|
||||
}
|
||||
} else {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
}
|
||||
}
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
||||
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
||||
@@ -862,7 +878,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||
final String oldValue = clientForUpdate.getMobileNo();
|
||||
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
||||
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldMobileNoForNotification = oldValue;
|
||||
@@ -879,7 +895,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||
final String oldValue = clientForUpdate.getEmailAddress();
|
||||
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldEmailAddressForNotification = oldValue;
|
||||
|
||||
+18
-3
@@ -19,8 +19,12 @@
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
@@ -33,14 +37,18 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
|
||||
|
||||
private final ClientRiskRuleRepository repository;
|
||||
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<KycRiskRuleData> retrieveAllRules() {
|
||||
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
|
||||
return rules.stream().map(this::mapToData).collect(Collectors.toList());
|
||||
final Map<Long, AmlTransactionRule> txnRulesBySourceId = this.amlTransactionRuleRepository.findAll().stream()
|
||||
.filter(txn -> txn.getSourceClientRiskRuleId() != null)
|
||||
.collect(Collectors.toMap(AmlTransactionRule::getSourceClientRiskRuleId, Function.identity(), (a, b) -> a));
|
||||
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule) {
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
|
||||
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
||||
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
||||
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
|
||||
@@ -67,7 +75,14 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
rule.getBannedEntityMatch(),
|
||||
rule.getWillfulDefaulter(),
|
||||
rule.getEcgcSalFlag(),
|
||||
rule.getDormantFlag()
|
||||
rule.getDormantFlag(),
|
||||
txnRule != null ? txnRule.getThresholdAmount() : null,
|
||||
txnRule != null ? txnRule.getRuleType().name() : null,
|
||||
txnRule != null ? txnRule.getWindowDays() : null,
|
||||
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
|
||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||
txnRule != null ? txnRule.getMaxLimitAmount() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,4 +499,13 @@
|
||||
<include file="parts/0444_add_kyc_exception_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_create_kyc_contact_log.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0446_create_clerk_share_closure_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0447_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0448_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0449_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0450_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0451_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0452_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?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">
|
||||
|
||||
<!-- Optional risk-category-tiered thresholds. Null on both columns means the rule is not
|
||||
tiered and threshold_amount alone is used (unchanged behaviour for every pre-existing rule). -->
|
||||
<changeSet author="fineract" id="0438-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_medium_risk"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="threshold_amount_medium_risk" type="DECIMAL(19,6)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_high_risk"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="threshold_amount_high_risk" type="DECIMAL(19,6)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- RTGS monitoring: Medium-risk clients are flagged at INR 5,00,000, High-risk at INR 20,00,000.
|
||||
threshold_amount (2,000,000) remains the fallback for Low-risk/unclassified clients. -->
|
||||
<changeSet author="fineract" id="0438-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'RTGS_HIGH_VALUE' AND threshold_amount_medium_risk IS NOT NULL</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule
|
||||
SET threshold_amount_medium_risk = 500000,
|
||||
threshold_amount_high_risk = 2000000
|
||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Rule 1 from the legacy Rules Parameter catalog: "Maintaining a low or overdrawn balance
|
||||
with high activity" (order_position 1). The legacy screen reports a single INR 10,00,000
|
||||
figure across three distinct parameters (average balance period, minimum balance, max
|
||||
transaction count), which does not correspond to any threshold/window shape this catalog
|
||||
currently evaluates (THRESHOLD/STRUCTURING/VELOCITY all key off a transaction AMOUNT, not
|
||||
an account balance level). Seeded here for catalog/display parity with m_client_risk_rule,
|
||||
with is_active = false so it is never picked up by AmlRuleEvaluationServiceImpl until a
|
||||
balance-based rule type is built. -->
|
||||
<changeSet author="fineract" id="0438-4">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY'</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
|
||||
('LOW_BALANCE_HIGH_ACTIVITY', 'Maintaining a low or overdrawn balance with high activity', 'SAVINGS', 1000000, 'THRESHOLD', null,
|
||||
(SELECT id FROM m_client_risk_rule WHERE order_position = 1 AND aml_check_status IS NOT NULL), false);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?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">
|
||||
|
||||
<changeSet author="fineract" id="0439-1">
|
||||
<validCheckSum>*</validCheckSum>
|
||||
<sql>
|
||||
-- 1. High Risk Occupations (Real Estate, Importer/Exporter, Commission Agent, Financial Advisor)
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 200000.0,
|
||||
min_annual_income = 2400000.0
|
||||
WHERE order_position IN (20, 30, 40, 50);
|
||||
|
||||
-- 2. Medium Risk Occupations
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 50000.0,
|
||||
max_monthly_income = 200000.0,
|
||||
min_annual_income = 600000.0,
|
||||
max_annual_income = 2400000.0
|
||||
WHERE order_position BETWEEN 90 AND 240;
|
||||
|
||||
-- 3. Low Risk Occupations
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 0.0,
|
||||
max_monthly_income = 50000.0,
|
||||
min_annual_income = 0.0,
|
||||
max_annual_income = 600000.0
|
||||
WHERE order_position BETWEEN 280 AND 450;
|
||||
|
||||
-- 4. Set all remaining NULL values to 0.00
|
||||
UPDATE m_client_risk_rule SET min_monthly_income = 0.0 WHERE min_monthly_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_monthly_income = 0.0 WHERE max_monthly_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET min_annual_income = 0.0 WHERE min_annual_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_annual_income = 0.0 WHERE max_annual_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET min_annual_turnover = 0.0 WHERE min_annual_turnover IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_annual_turnover = 0.0 WHERE max_annual_turnover IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<?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">
|
||||
|
||||
<changeSet author="fineract" id="0440-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_client (
|
||||
account_no, external_id, status_enum, office_id, display_name,
|
||||
fullname, is_staff, activation_date, office_joining_date,
|
||||
submittedon_date, legal_form_enum,
|
||||
created_on_utc, created_by,
|
||||
last_modified_on_utc, last_modified_by,
|
||||
cif_number
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'ACC0000002', 'CLIENT_RAJESH', 300, 1, 'Rajesh Kumar',
|
||||
'Rajesh Kumar', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_RAJESH'
|
||||
),
|
||||
(
|
||||
'ACC0000003', 'CLIENT_SONIA', 300, 1, 'Sonia Sharma',
|
||||
'Sonia Sharma', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_SONIA'
|
||||
),
|
||||
(
|
||||
'ACC0000004', 'CLIENT_VIKRAM', 300, 1, 'Vikram Malhotra',
|
||||
'Vikram Malhotra', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_VIKRAM'
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0440-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_aml_alert a
|
||||
JOIN m_client c ON a.client_id = c.id
|
||||
WHERE c.external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA', 'CLIENT_VIKRAM')
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_alert (
|
||||
transaction_type, transaction_id, client_id, rule_id, amount, currency_code,
|
||||
raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'SAVINGS', 1001,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'),
|
||||
3500000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
),
|
||||
(
|
||||
'SAVINGS', 1002,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'),
|
||||
995000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
),
|
||||
(
|
||||
'SAVINGS', 1003,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_VIKRAM'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'PEP_ACCOUNT_MONITORING'),
|
||||
75000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Declarative min/max band for each AML transaction rule, used by the AML Rules
|
||||
Master screen.
|
||||
|
||||
These are deliberately NOT the existing threshold_amount_medium_risk /
|
||||
threshold_amount_high_risk columns: those are live alerting overrides consumed by
|
||||
AmlRuleEvaluationServiceImpl.resolveThreshold(), and populating them purely to fill
|
||||
a display column would change which transactions raise alerts. These new columns
|
||||
are read-only documentation of the band a rule covers and never affect matching.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_aml_transaction_rule" columnName="min_limit_amount"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="min_limit_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="max_limit_amount" type="DECIMAL(19,6)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Band values. The minimum is the amount at which the rule starts to bite; the maximum
|
||||
is the amount beyond which the exposure is treated as materially more serious and
|
||||
escalated. A NULL maximum means the rule is genuinely open-ended.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-2">
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'CTR_THRESHOLD_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'STRUCTURED_CTR_10L_MONTHLY';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2500000, max_limit_amount = 5000000
|
||||
WHERE rule_code = 'LARGE_CASH_TXN_25L';
|
||||
-- Structuring rules watch the band immediately BELOW the reporting threshold.
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 700000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'STRUCTURING_BELOW_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 35000, max_limit_amount = 50000
|
||||
WHERE rule_code = 'STRUCTURING_BELOW_50K';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 900000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'FREQUENT_CASH_NEAR_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'FREQUENT_LOW_CASH_DEPOSIT';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'FREQUENT_LOW_CASH_WITHDRAWAL';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 2000000
|
||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2000000, max_limit_amount = 5000000
|
||||
WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 500000
|
||||
WHERE rule_code = 'PEP_ACCOUNT_MONITORING';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 200000, max_limit_amount = 500000
|
||||
WHERE rule_code = 'LOAN_REPAYMENT_IN_CASH';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY';
|
||||
-- RAPID_MOVEMENT_IN_ACCOUNTS is a pure velocity rule with no monetary threshold,
|
||||
-- so it is intentionally left without a band.
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The three High-risk income rules were seeded with a floor but a zero ceiling, which the
|
||||
UI cannot distinguish from "not captured". Normalise the zeros to NULL so an absent
|
||||
ceiling reads as genuinely open-ended, and give the medium bands their missing floors.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-3">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule SET max_monthly_income = NULL WHERE max_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_income = NULL WHERE max_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_turnover = NULL WHERE max_annual_turnover = 0;
|
||||
UPDATE m_client_risk_rule SET min_monthly_income = NULL WHERE min_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_income = NULL WHERE min_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_turnover = NULL WHERE min_annual_turnover = 0;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Branch offices under Head Office.
|
||||
|
||||
Until now the tenant had a single office, so "see only my branch" had nothing to
|
||||
scope to. external_id carries the branch code used by the General Ledger and the
|
||||
RBI return screens, which is what lets a logged-in user's office be resolved to a
|
||||
branch without matching on display names.
|
||||
-->
|
||||
<changeSet author="fineract" id="0442-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006')</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_office (parent_id, hierarchy, external_id, name, opening_date)
|
||||
VALUES
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.2.', '0002', 'Andheri (E)', DATE '2018-04-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.3.', '0003', 'Bandra (W)', DATE '2018-04-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.4.', '0004', 'Borivali', DATE '2019-07-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.5.', '0005', 'Dadar', DATE '2020-01-02'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.6.', '0006', 'Thane', DATE '2021-06-01');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Head Office carries the branch code the GL ledger uses for it. -->
|
||||
<changeSet author="fineract" id="0442-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id = 'HO'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</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>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?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">
|
||||
|
||||
<!-- Email confirmation tracking for the Client edit approval workflow.
|
||||
When a Clerk approves a pending client edit, a confirmation email is sent to the
|
||||
client's registered email address. These six columns record the outcome of that send
|
||||
and allow staff to see whether the client has opened the email (via a 1x1 tracking
|
||||
pixel whose URL embeds the UUID email_tracking_token).
|
||||
|
||||
email_subject / email_body store the exact content that was sent so a "View Email"
|
||||
dialog can show it later without regenerating the template.
|
||||
|
||||
email_tracking_token is a random UUID generated at send time — looked up directly by
|
||||
the public pixel endpoint (GET /api/*/clients/edit/pixel/{token}) so the endpoint
|
||||
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
|
||||
|
||||
<changeSet author="CBS" id="0444-1-add-email-tracking-token">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_tracking_token" type="VARCHAR(36)">
|
||||
<constraints nullable="true" unique="true"
|
||||
uniqueConstraintName="uq_client_pending_edit_email_token"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-2-add-email-sent">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-3-add-email-sent-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-4-add-email-opened-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-5-add-email-subject">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_subject" type="VARCHAR(300)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-6-add-email-body">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_body" type="TEXT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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.1.xsd">
|
||||
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_from" type="VARCHAR(255)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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.1.xsd">
|
||||
<changeSet id="1" author="fineract">
|
||||
<createTable tableName="m_family_member_children">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="family_member_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="full_name" type="VARCHAR(255)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="gender_cv_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="date_of_birth" type="DATE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="family_member_id" constraintName="fk_family_member_children_family_member_id" referencedTableName="m_family_members" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="gender_cv_id" constraintName="fk_family_member_children_gender_cv_id" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+1
@@ -116,6 +116,7 @@
|
||||
<class>org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat</class>
|
||||
|
||||
<!-- Client Module Entities -->
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMembers</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientNonPerson</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientTransferDetails</class>
|
||||
|
||||
Reference in New Issue
Block a user