diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index e8c01f135..ad0836c81 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -35,4 +35,10 @@ public interface AmlAlertRepository extends JpaRepository { @Param("since") LocalDateTime since); long countByClientId(Long clientId); + + /** + * Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked + * twice, which would put two STR cases in front of the Principal Officer for a single transaction. + */ + List findByRule_IdAndTransactionId(Long ruleId, Long transactionId); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java index 103c16bc8..44c110345 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java @@ -52,6 +52,26 @@ public class AmlTransactionRule { @Column(name = "threshold_amount", precision = 19, scale = 6) private BigDecimal thresholdAmount; + /** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is + * not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */ + @Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6) + private BigDecimal thresholdAmountMediumRisk; + + @Column(name = "threshold_amount_high_risk", precision = 19, scale = 6) + private BigDecimal thresholdAmountHighRisk; + + /** + * Declarative band the rule covers, shown on the AML Rules Master screen. Purely + * descriptive: unlike the tiered columns above these never take part in matching, so + * changing them cannot alter which transactions raise an alert. A null maximum means + * the rule is genuinely open-ended. + */ + @Column(name = "min_limit_amount", precision = 19, scale = 6) + private BigDecimal minLimitAmount; + + @Column(name = "max_limit_amount", precision = 19, scale = 6) + private BigDecimal maxLimitAmount; + @Column(name = "rule_type", nullable = false, length = 20) private String ruleType; @@ -86,6 +106,22 @@ public class AmlTransactionRule { return thresholdAmount; } + public BigDecimal getThresholdAmountMediumRisk() { + return thresholdAmountMediumRisk; + } + + public BigDecimal getThresholdAmountHighRisk() { + return thresholdAmountHighRisk; + } + + public BigDecimal getMinLimitAmount() { + return minLimitAmount; + } + + public BigDecimal getMaxLimitAmount() { + return maxLimitAmount; + } + public AmlRuleType getRuleType() { return AmlRuleType.valueOf(ruleType); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java index f654d5672..19b5eae0b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java @@ -19,9 +19,17 @@ package org.apache.fineract.portfolio.aml.domain; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface AmlTransactionRuleRepository extends JpaRepository { List findByActiveTrueAndAppliesTo(String appliesTo); + + /** + * Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately + * seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises + * by hand from the ledger. + */ + Optional findByRuleCode(String ruleCode); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberChildData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberChildData.java new file mode 100644 index 000000000..803ac9426 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberChildData.java @@ -0,0 +1,35 @@ +/** + * 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.data; + +import java.io.Serializable; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public final class ClientFamilyMemberChildData implements Serializable { + private final Long id; + private final String fullName; + private final Long genderId; + private final String gender; + private final LocalDate dateOfBirth; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java index 2df39536d..c5c5a30e6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java @@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable { private final LocalDate marriageDate; private final String marriageAuthority; + @lombok.Setter + private java.util.List children; + // template holder private final Collection relationshipIdOptions; private final Collection genderIdOptions; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 57464fdcc..c740cf276 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -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 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. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java index 9fe458bf0..498738d70 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java @@ -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 toApiJsonSerializer; + private final DefaultToApiJsonSerializer 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); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java new file mode 100644 index 000000000..c6f4381b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ManualReferralRequest.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java new file mode 100644 index 000000000..d990d4825 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/ManualReferralData.java @@ -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 {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 8063dae09..dea405fb5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -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 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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java new file mode 100644 index 000000000..77f9e0ed8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralService.java @@ -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 retrieveAll(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java new file mode 100644 index 000000000..db4ec204d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -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 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 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 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java index f2437a0c0..8986556c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java @@ -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 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 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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java index 28dcd60e0..5dc927e18 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java @@ -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 CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java index ddd3a9452..8556c3de0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java @@ -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() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java index aaeb9f137..89400476f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java @@ -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. } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java index 7bf4afad6..7eebbe34a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java @@ -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; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMemberChildren.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMemberChildren.java new file mode 100644 index 000000000..726dd799e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMemberChildren.java @@ -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 { + + @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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java index a363d627b..afcd24f15 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java @@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { @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 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 { public LocalDateTime getCreatedAt() { return createdAt; } public Long getModifiedBy() { return modifiedBy; } public LocalDateTime getModifiedOn() { return modifiedOn; } + + public java.util.List getChildren() { + return this.children; + } + + public void setChildren(java.util.List children) { + if (this.children == null) { + this.children = new java.util.ArrayList<>(); + } else { + this.children.clear(); + } + if (children != null) { + this.children.addAll(children); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java index 96bb93a8d..11426d2f0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java @@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom { @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 { 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 { 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(); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java index a819e8128..b5d269ee3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java @@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository findByClientIdAndStatus(Long clientId, String status); List findByStatusOrderBySubmittedDateDesc(String status); + + /** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */ + Optional findByEmailTrackingToken(String emailTrackingToken); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java index 97c1c8112..3b00ddd18 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java @@ -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 findByEmailTrackingToken(final String token) { + return this.repository.findByEmailTrackingToken(token); + } + public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) { return this.repository.saveAndFlush(edit); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java index 66abfe42b..0fb2de5d8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java @@ -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 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailService.java new file mode 100644 index 000000000..60ebc41e4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailService.java @@ -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. + * + *

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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailServiceImpl.java new file mode 100644 index 000000000..2a1ee0d1a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditEmailServiceImpl.java @@ -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). + * + *

Template selection: + *

    + *
  • Template A (Contact Change) — 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.
  • + *
  • Template B (General Change) — used for all other field changes, or when + * {@code original_json} is null (pre-snapshot edits). Lists changed field labels.
  • + *
+ * + *

This service is intentionally non-transactional: 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 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("") + .append("").append(escHtml(label)).append("") + .append("") + .append(oldVal != null ? escHtml(oldVal) : "—").append("") + .append("") + .append(escHtml(newVal)).append("") + .append(""); + } + + return "" + + "

Dear " + escHtml(clientName) + ",

" + + "

Your contact details with " + BANK_NAME + " have been " + + "successfully updated as of " + escHtml(date) + ".

" + + "" + + "" + + "" + + "" + + "" + + "" + rows + "
FieldPrevious ValueNew Value
" + + "

If you did not request this change, please contact your branch immediately.

" + + "

This is an automated message. Please do not reply to this email.

" + + "" + + ""; + } + + // ── 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 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("
  • ").append(escHtml(label)).append("
  • "); + } + final String listHtml = items.length() > 0 + ? "
      " + items + "
    " + : "

    No specific fields identified.

    "; + + return "" + + "

    Dear " + escHtml(clientName) + ",

    " + + "

    Your profile details with " + BANK_NAME + " have been " + + "successfully updated as of " + escHtml(date) + ".

    " + + "

    The following fields were changed:

    " + + listHtml + + "

    If you did not authorise this change, please contact your branch immediately.

    " + + "

    This is an automated message. Please do not reply to this email.

    " + + "" + + ""; + } + + // ── 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("'", "'"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java index 4bb8011be..8eedfb019 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java index d56095e39..51f1b3fcd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java index 7c0c4ef96..7f821b510 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java @@ -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 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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index 8fa38b8c8..34b0f1c01 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -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 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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java index aa3191581..5e88980f3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java @@ -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 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 retrieveEmailPreview(Long clientId, Long pendingEditId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java index be2e05f83..7956df868 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java @@ -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 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 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 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; + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index 86eb53575..5c1183720 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java index 285ecbe70..4e6893ab9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -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 retrieveAllRules() { final List rules = this.repository.findAllOrderedByOrderPosition(); - return rules.stream().map(this::mapToData).collect(Collectors.toList()); + final Map 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 ); } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index c84de8987..6ea4f3c0d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -499,4 +499,13 @@ + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0447_add_tiered_thresholds_and_low_balance_rule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0447_add_tiered_thresholds_and_low_balance_rule.xml new file mode 100644 index 000000000..7bc7454ea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0447_add_tiered_thresholds_and_low_balance_rule.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'RTGS_HIGH_VALUE' AND threshold_amount_medium_risk IS NOT NULL + + + UPDATE m_aml_transaction_rule + SET threshold_amount_medium_risk = 500000, + threshold_amount_high_risk = 2000000 + WHERE rule_code = 'RTGS_HIGH_VALUE'; + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY' + + + 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); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0448_fill_client_risk_rule_income_turnover_nulls.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0448_fill_client_risk_rule_income_turnover_nulls.xml new file mode 100644 index 000000000..f51108229 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0448_fill_client_risk_rule_income_turnover_nulls.xml @@ -0,0 +1,61 @@ + + + + + + * + + -- 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml new file mode 100644 index 000000000..00d74d25f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml @@ -0,0 +1,108 @@ + + + + + + + SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH' + + + 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' + ); + + + + + + + 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') + + + + 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 + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0450_add_aml_rule_limit_bands.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0450_add_aml_rule_limit_bands.xml new file mode 100644 index 000000000..abe1d7e92 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0450_add_aml_rule_limit_bands.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + 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. + + + + + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml new file mode 100644 index 000000000..bc0a1846a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml @@ -0,0 +1,59 @@ + + + + + + + + SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006') + + + 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'); + + + + + + + SELECT COUNT(*) FROM m_office WHERE external_id = 'HO' + + + UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0452_seed_aml_manual_referral_rule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0452_seed_aml_manual_referral_rule.xml new file mode 100644 index 000000000..85d15f767 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0452_seed_aml_manual_referral_rule.xml @@ -0,0 +1,45 @@ + + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL' + + + 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); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml new file mode 100644 index 000000000..d12b7072f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml new file mode 100644 index 000000000..6404c3ed7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml new file mode 100644 index 000000000..e3762d6c1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index 8be96e889..150a52536 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -116,6 +116,7 @@ org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat + org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren org.apache.fineract.portfolio.client.domain.ClientFamilyMembers org.apache.fineract.portfolio.client.domain.ClientNonPerson org.apache.fineract.portfolio.client.domain.ClientTransferDetails