Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d22382a5 | ||
|
|
7cdc25322e | ||
|
|
a4c3dbb6c4 | ||
|
|
dd8445de06 | ||
|
|
9345f2294c | ||
|
|
190cd92a85 |
-6
@@ -35,10 +35,4 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
|||||||
@Param("since") LocalDateTime since);
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
long countByClientId(Long clientId);
|
long countByClientId(Long clientId);
|
||||||
|
|
||||||
/**
|
|
||||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
|
||||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
|
||||||
*/
|
|
||||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-36
@@ -52,26 +52,6 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
||||||
private BigDecimal thresholdAmount;
|
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)
|
@Column(name = "rule_type", nullable = false, length = 20)
|
||||||
private String ruleType;
|
private String ruleType;
|
||||||
|
|
||||||
@@ -106,22 +86,6 @@ public class AmlTransactionRule {
|
|||||||
return thresholdAmount;
|
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() {
|
public AmlRuleType getRuleType() {
|
||||||
return AmlRuleType.valueOf(ruleType);
|
return AmlRuleType.valueOf(ruleType);
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -19,17 +19,9 @@
|
|||||||
package org.apache.fineract.portfolio.aml.domain;
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||||
|
|
||||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
||||||
|
|
||||||
/**
|
|
||||||
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
|
|
||||||
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
|
|
||||||
* by hand from the ledger.
|
|
||||||
*/
|
|
||||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
-3
@@ -84,9 +84,6 @@ public final class ClientFamilyMembersData implements Serializable {
|
|||||||
private final LocalDate marriageDate;
|
private final LocalDate marriageDate;
|
||||||
private final String marriageAuthority;
|
private final String marriageAuthority;
|
||||||
|
|
||||||
@lombok.Setter
|
|
||||||
private java.util.List<ClientFamilyMemberChildData> children;
|
|
||||||
|
|
||||||
// template holder
|
// template holder
|
||||||
private final Collection<CodeValueData> relationshipIdOptions;
|
private final Collection<CodeValueData> relationshipIdOptions;
|
||||||
private final Collection<CodeValueData> genderIdOptions;
|
private final Collection<CodeValueData> genderIdOptions;
|
||||||
|
|||||||
-5
@@ -154,11 +154,6 @@ public class SecurityConfig {
|
|||||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||||
// Client edit approval email open-tracking pixel — hit by the client's email client
|
|
||||||
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
|
||||||
// path is the only capability credential; the endpoint always returns a GIF so
|
|
||||||
// token validity is not leaked via different HTTP responses.
|
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
|
||||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||||
// packages here without a Fineract Bearer token; security is enforced by the
|
// packages here without a Fineract Bearer token; security is enforced by the
|
||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
|
|||||||
-28
@@ -33,10 +33,8 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
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.AmlAlertReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/aml/alerts")
|
@Path("/v1/aml/alerts")
|
||||||
@@ -48,9 +46,7 @@ public class AmlAlertApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final AmlAlertReadPlatformService readPlatformService;
|
private final AmlAlertReadPlatformService readPlatformService;
|
||||||
private final AmlAlertWritePlatformService writePlatformService;
|
private final AmlAlertWritePlatformService writePlatformService;
|
||||||
private final JournalEntryReferralService referralService;
|
|
||||||
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
||||||
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
|
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -80,28 +76,4 @@ public class AmlAlertApiResource {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
return this.toApiJsonSerializer.serialize(updated);
|
return this.toApiJsonSerializer.serialize(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("manual-referrals")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "List ledger entries marked by a user",
|
|
||||||
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
|
|
||||||
public String retrieveManualReferrals() {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Path("manual-referral")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Mark a ledger entry cleared or suspicious",
|
|
||||||
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
|
|
||||||
public String referJournalEntry(final ManualReferralRequest request) {
|
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
|
||||||
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
|
|
||||||
request.getNotes());
|
|
||||||
return this.referralJsonSerializer.serialize(referral);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.aml.api;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
|
|
||||||
*
|
|
||||||
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
|
|
||||||
* no report) or ESCALATE (raises an STR case for the Principal Officer).
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ManualReferralRequest {
|
|
||||||
|
|
||||||
private Long journalEntryId;
|
|
||||||
private String disposition;
|
|
||||||
private String notes;
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 {}
|
|
||||||
+3
-32
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -28,8 +27,6 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
|||||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -38,12 +35,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional
|
@Transactional
|
||||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
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 AmlTransactionRuleRepository ruleRepository;
|
||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
private final ClientKycRepository clientKycRepository;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||||
@@ -63,42 +56,20 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
if (rule.getThresholdAmount() == null) {
|
||||||
if (threshold == null) {
|
|
||||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
// 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.
|
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
switch (rule.getRuleType()) {
|
switch (rule.getRuleType()) {
|
||||||
case THRESHOLD:
|
case THRESHOLD:
|
||||||
return context.getAmount().compareTo(threshold) >= 0;
|
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||||
case STRUCTURING:
|
case STRUCTURING:
|
||||||
case VELOCITY:
|
case VELOCITY:
|
||||||
return context.getWindowAggregateAmount() != null
|
return context.getWindowAggregateAmount() != null
|
||||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
|
||||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
|
||||||
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
|
|
||||||
* clients.
|
|
||||||
*/
|
|
||||||
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
|
|
||||||
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
|
|
||||||
return rule.getThresholdAmount();
|
|
||||||
}
|
|
||||||
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
|
|
||||||
.orElse(null);
|
|
||||||
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
|
|
||||||
return rule.getThresholdAmountHighRisk();
|
|
||||||
}
|
|
||||||
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
|
|
||||||
return rule.getThresholdAmountMediumRisk();
|
|
||||||
}
|
|
||||||
return rule.getThresholdAmount();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
|
|
||||||
*
|
|
||||||
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
|
|
||||||
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
|
|
||||||
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
|
|
||||||
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
|
|
||||||
*/
|
|
||||||
public interface JournalEntryReferralService {
|
|
||||||
|
|
||||||
/** disposition: WHITELIST | ESCALATE. */
|
|
||||||
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
|
|
||||||
|
|
||||||
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
|
|
||||||
List<ManualReferralData> retrieveAll();
|
|
||||||
}
|
|
||||||
-224
@@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Transactional
|
|
||||||
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
|
|
||||||
|
|
||||||
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
|
|
||||||
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final AmlAlertRepository alertRepository;
|
|
||||||
private final AmlStrCaseRepository strCaseRepository;
|
|
||||||
private final AmlTransactionRuleRepository ruleRepository;
|
|
||||||
private final ClientRepositoryWrapper clientRepository;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
|
|
||||||
* posted against.
|
|
||||||
*
|
|
||||||
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
|
|
||||||
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
|
|
||||||
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
|
|
||||||
* a savings-type entity may be either a savings or a current account, which live in separate tables.
|
|
||||||
*
|
|
||||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
|
||||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
|
||||||
*/
|
|
||||||
private static final String RESOLVE_SQL = """
|
|
||||||
SELECT je.amount AS amount,
|
|
||||||
je.currency_code AS currencyCode,
|
|
||||||
je.savings_transaction_id AS savingsTxnId,
|
|
||||||
je.loan_transaction_id AS loanTxnId,
|
|
||||||
je.entity_type_enum AS entityType,
|
|
||||||
COALESCE(
|
|
||||||
esa.client_id,
|
|
||||||
eca.client_id,
|
|
||||||
eloan.client_id,
|
|
||||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
|
||||||
sa.client_id,
|
|
||||||
l.client_id,
|
|
||||||
ct.client_id
|
|
||||||
) AS clientId
|
|
||||||
FROM acc_gl_journal_entry je
|
|
||||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
|
||||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
|
||||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
|
||||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
|
||||||
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
|
|
||||||
LEFT JOIN m_loan l ON l.id = lt.loan_id
|
|
||||||
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
|
|
||||||
WHERE je.id = ?
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static final String LIST_SQL = """
|
|
||||||
SELECT a.id AS alertId,
|
|
||||||
a.transaction_id AS journalEntryId,
|
|
||||||
a.client_id AS clientId,
|
|
||||||
c.display_name AS clientDisplayName,
|
|
||||||
a.amount AS amount,
|
|
||||||
a.currency_code AS currencyCode,
|
|
||||||
a.raised_on AS raisedOn,
|
|
||||||
a.status AS status,
|
|
||||||
a.disposition AS disposition,
|
|
||||||
u.username AS reviewedByUsername,
|
|
||||||
a.review_notes AS reviewNotes,
|
|
||||||
s.id AS strCaseId
|
|
||||||
FROM m_aml_alert a
|
|
||||||
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
|
|
||||||
LEFT JOIN m_client c ON c.id = a.client_id
|
|
||||||
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
|
|
||||||
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
|
|
||||||
ORDER BY a.raised_on DESC
|
|
||||||
""";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
|
|
||||||
if (journalEntryId == null) {
|
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
|
|
||||||
"A journal entry must be given to refer.");
|
|
||||||
}
|
|
||||||
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
|
|
||||||
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
|
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
|
|
||||||
"Disposition must be WHITELIST or ESCALATE.", disposition);
|
|
||||||
}
|
|
||||||
|
|
||||||
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
|
|
||||||
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
|
|
||||||
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
|
|
||||||
|
|
||||||
// Marking the same entry twice would put two cases in front of the Principal Officer for
|
|
||||||
// one transaction, so the first mark stands until it is explicitly reworked.
|
|
||||||
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
|
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
|
|
||||||
"This ledger entry has already been marked.", journalEntryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> row = loadEntry(journalEntryId);
|
|
||||||
final Number clientIdValue = (Number) row.get("clientId");
|
|
||||||
if (clientIdValue == null) {
|
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
|
|
||||||
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
|
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
|
||||||
final String currencyCode = (String) row.get("currencyCode");
|
|
||||||
|
|
||||||
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
|
|
||||||
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
|
|
||||||
// savings or loan transaction id.
|
|
||||||
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
|
|
||||||
LocalDateTime.now());
|
|
||||||
alertRepository.save(alert);
|
|
||||||
|
|
||||||
Long strCaseId = null;
|
|
||||||
if (escalate) {
|
|
||||||
alert.escalate(context.authenticatedUser(), notes);
|
|
||||||
alertRepository.save(alert);
|
|
||||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
|
||||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
|
||||||
strCaseId = strCase.getId();
|
|
||||||
} else {
|
|
||||||
alert.whitelist(context.authenticatedUser(), notes);
|
|
||||||
alertRepository.save(alert);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
|
|
||||||
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
|
|
||||||
alert.getDisposition() == null ? null : alert.getDisposition().name(),
|
|
||||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
|
|
||||||
strCaseId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ManualReferralData> retrieveAll() {
|
|
||||||
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
|
|
||||||
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
|
|
||||||
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
|
|
||||||
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
|
|
||||||
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
|
|
||||||
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
|
|
||||||
MANUAL_RULE_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadEntry(final Long journalEntryId) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
|
|
||||||
} catch (final EmptyResultDataAccessException e) {
|
|
||||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
|
|
||||||
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TRANSFER covers everything that is neither a savings nor a loan posting.
|
|
||||||
*
|
|
||||||
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
|
|
||||||
* id; falling straight through to the transaction columns would mislabel those as transfers.
|
|
||||||
*/
|
|
||||||
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
|
|
||||||
final Number entityType = (Number) row.get("entityType");
|
|
||||||
if (entityType != null) {
|
|
||||||
if (entityType.intValue() == 2) {
|
|
||||||
return AmlTransactionType.SAVINGS;
|
|
||||||
}
|
|
||||||
if (entityType.intValue() == 1) {
|
|
||||||
return AmlTransactionType.LOAN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row.get("savingsTxnId") != null) {
|
|
||||||
return AmlTransactionType.SAVINGS;
|
|
||||||
}
|
|
||||||
if (row.get("loanTxnId") != null) {
|
|
||||||
return AmlTransactionType.LOAN;
|
|
||||||
}
|
|
||||||
return AmlTransactionType.TRANSFER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-84
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.client.api;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
@@ -32,9 +31,7 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
@@ -46,11 +43,8 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
|||||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
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.ClientEditWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/clients")
|
@Path("/v1/clients")
|
||||||
@@ -62,21 +56,9 @@ import org.springframework.stereotype.Component;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClientEditApiResource {
|
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 PlatformSecurityContext context;
|
||||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
@@ -142,26 +124,14 @@ public class ClientEditApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Approve a pending Client details edit",
|
@Operation(summary = "Approve a pending Client details edit",
|
||||||
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
||||||
+ "The maker of the edit cannot also approve it — no exceptions.")
|
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
|
||||||
@Context final HttpServletRequest httpRequest) {
|
|
||||||
|
|
||||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
||||||
// 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);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
@@ -191,55 +161,4 @@ public class ClientEditApiResource {
|
|||||||
|
|
||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Email preview (authenticated) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
|
||||||
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
|
||||||
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
|
||||||
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
|
||||||
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
|
||||||
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
|
||||||
|
|
||||||
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
|
||||||
if (preview == null) {
|
|
||||||
return Response.status(Response.Status.NOT_FOUND).build();
|
|
||||||
}
|
|
||||||
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("edit/pixel/{token}")
|
|
||||||
@Produces("image/gif")
|
|
||||||
@Operation(summary = "Email open tracking pixel",
|
|
||||||
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
|
||||||
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
|
||||||
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
|
||||||
public Response trackEmailOpen(@PathParam("token") final String token) {
|
|
||||||
if (token != null && !token.isBlank()) {
|
|
||||||
try {
|
|
||||||
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
|
||||||
if (pendingEditOpt.isPresent()) {
|
|
||||||
final var pendingEdit = pendingEditOpt.get();
|
|
||||||
pendingEdit.markEmailOpened();
|
|
||||||
pendingEditRepository.saveAndFlush(pendingEdit);
|
|
||||||
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
|
||||||
}
|
|
||||||
} catch (final Exception e) {
|
|
||||||
// Never fail a pixel request — email clients don't handle errors well.
|
|
||||||
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
|
||||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
||||||
.header("Pragma", "no-cache")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -65,8 +65,7 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||||
nameVerifiedParamName, screeningLogIdParamName,
|
nameVerifiedParamName, screeningLogIdParamName,
|
||||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
||||||
"oldMobileVerificationExceptionDocumentId"));
|
|
||||||
|
|
||||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||||
|
|||||||
-5
@@ -533,11 +533,6 @@ public final class ClientDataValidator {
|
|||||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||||
.longGreaterThanZero();
|
.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)) {
|
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
||||||
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
||||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
||||||
|
|||||||
-14
@@ -41,18 +41,4 @@ public class ClientPendingEditData {
|
|||||||
private final String checkerName;
|
private final String checkerName;
|
||||||
private final OffsetDateTime checkedDate;
|
private final OffsetDateTime checkedDate;
|
||||||
private final String rejectReason;
|
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.
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-53
@@ -46,20 +46,6 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
private final String willfulDefaulter;
|
private final String willfulDefaulter;
|
||||||
private final String ecgcSalFlag;
|
private final String ecgcSalFlag;
|
||||||
private final String dormantFlag;
|
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,
|
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
@@ -69,10 +55,7 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
||||||
final Integer riskCategory, final String reason, final Integer orderPosition,
|
final Integer riskCategory, final String reason, final Integer orderPosition,
|
||||||
final String amlCheckStatus, final String bannedEntityMatch,
|
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.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
this.legalFormId = legalFormId;
|
this.legalFormId = legalFormId;
|
||||||
@@ -94,13 +77,6 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
this.willfulDefaulter = willfulDefaulter;
|
this.willfulDefaulter = willfulDefaulter;
|
||||||
this.ecgcSalFlag = ecgcSalFlag;
|
this.ecgcSalFlag = ecgcSalFlag;
|
||||||
this.dormantFlag = dormantFlag;
|
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() {
|
public Long getId() {
|
||||||
@@ -186,32 +162,4 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
public String getDormantFlag() {
|
public String getDormantFlag() {
|
||||||
return dormantFlag;
|
return dormantFlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BigDecimal getTxnThresholdAmount() {
|
|
||||||
return txnThresholdAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getTxnRuleType() {
|
|
||||||
return txnRuleType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getTxnWindowDays() {
|
|
||||||
return txnWindowDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getTxnThresholdAmountMediumRisk() {
|
|
||||||
return txnThresholdAmountMediumRisk;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getTxnThresholdAmountHighRisk() {
|
|
||||||
return txnThresholdAmountHighRisk;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getTxnMinLimitAmount() {
|
|
||||||
return txnMinLimitAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getTxnMaxLimitAmount() {
|
|
||||||
return txnMaxLimitAmount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-96
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_family_member_children")
|
|
||||||
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "family_member_id", nullable = false)
|
|
||||||
private ClientFamilyMembers familyMember;
|
|
||||||
|
|
||||||
@Column(name = "full_name", nullable = false)
|
|
||||||
private String fullName;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "gender_cv_id", nullable = false)
|
|
||||||
private CodeValue gender;
|
|
||||||
|
|
||||||
@Column(name = "date_of_birth", nullable = true)
|
|
||||||
private LocalDate dateOfBirth;
|
|
||||||
|
|
||||||
protected ClientFamilyMemberChildren() {
|
|
||||||
// Default constructor
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
|
||||||
final LocalDate dateOfBirth) {
|
|
||||||
this.familyMember = familyMember;
|
|
||||||
this.fullName = fullName;
|
|
||||||
this.gender = gender;
|
|
||||||
this.dateOfBirth = dateOfBirth;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
|
||||||
final LocalDate dateOfBirth) {
|
|
||||||
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClientFamilyMembers getFamilyMember() {
|
|
||||||
return this.familyMember;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setFamilyMember(ClientFamilyMembers familyMember) {
|
|
||||||
this.familyMember = familyMember;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFullName() {
|
|
||||||
return this.fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setFullName(String fullName) {
|
|
||||||
this.fullName = fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CodeValue getGender() {
|
|
||||||
return this.gender;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setGender(CodeValue gender) {
|
|
||||||
this.gender = gender;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getDateOfBirth() {
|
|
||||||
return this.dateOfBirth;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
|
||||||
this.dateOfBirth = dateOfBirth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-18
@@ -151,9 +151,6 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "modified_on")
|
@Column(name = "modified_on")
|
||||||
private LocalDateTime modifiedOn;
|
private LocalDateTime modifiedOn;
|
||||||
|
|
||||||
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
|
|
||||||
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
|
||||||
|
|
||||||
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
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 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,
|
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
||||||
@@ -359,19 +356,4 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
|||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public Long getModifiedBy() { return modifiedBy; }
|
public Long getModifiedBy() { return modifiedBy; }
|
||||||
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
||||||
|
|
||||||
public java.util.List<ClientFamilyMemberChildren> getChildren() {
|
|
||||||
return this.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
|
|
||||||
if (this.children == null) {
|
|
||||||
this.children = new java.util.ArrayList<>();
|
|
||||||
} else {
|
|
||||||
this.children.clear();
|
|
||||||
}
|
|
||||||
if (children != null) {
|
|
||||||
this.children.addAll(children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-54
@@ -89,41 +89,6 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "pending_lock_key", length = 60, unique = true)
|
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||||
private String pendingLockKey;
|
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,
|
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||||
final AppUser maker) {
|
final AppUser maker) {
|
||||||
final ClientPendingEdit edit = new ClientPendingEdit();
|
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||||
@@ -134,7 +99,6 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
edit.makerId = maker.getId();
|
edit.makerId = maker.getId();
|
||||||
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
edit.pendingLockKey = lockKeyFor(client.getId());
|
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||||
edit.emailSent = Boolean.FALSE;
|
|
||||||
return edit;
|
return edit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,22 +129,4 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
this.rejectReason = reason;
|
this.rejectReason = reason;
|
||||||
this.pendingLockKey = null;
|
this.pendingLockKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called after a successful email send. Records the token (for pixel tracking), the exact
|
|
||||||
* subject and body, and marks the row as sent. */
|
|
||||||
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
|
|
||||||
this.emailTrackingToken = token;
|
|
||||||
this.emailSubject = subject;
|
|
||||||
this.emailBody = body;
|
|
||||||
this.emailFrom = fromAddress;
|
|
||||||
this.emailSent = Boolean.TRUE;
|
|
||||||
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
|
|
||||||
public void markEmailOpened() {
|
|
||||||
if (this.emailOpenedAt == null) {
|
|
||||||
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -27,7 +27,4 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
|
|||||||
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||||
|
|
||||||
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||||
|
|
||||||
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
|
|
||||||
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-6
@@ -43,12 +43,6 @@ public class ClientPendingEditRepositoryWrapper {
|
|||||||
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
|
|
||||||
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
|
|
||||||
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
|
|
||||||
return this.repository.findByEmailTrackingToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||||
return this.repository.saveAndFlush(edit);
|
return this.repository.saveAndFlush(edit);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -76,13 +76,11 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
|
|||||||
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
||||||
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
||||||
|
|
||||||
public static final String CHILDREN = "children";
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
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,
|
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
||||||
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
||||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
|
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
|
||||||
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
||||||
public static final String RELATION_SHIP_ID = "relationShipId";
|
public static final String RELATION_SHIP_ID = "relationShipId";
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
|||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.client.service;
|
|
||||||
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
|
|
||||||
* their pending profile-edit. Also records the tracking token, subject and body back onto the
|
|
||||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
|
|
||||||
* dialog can reproduce it later without regenerating the template.
|
|
||||||
*
|
|
||||||
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
|
|
||||||
* that a mail-server outage never rolls back an already-committed approval.
|
|
||||||
*/
|
|
||||||
public interface ClientEditEmailService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to send a confirmation email to the client. On success, calls
|
|
||||||
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
|
|
||||||
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
|
|
||||||
* {@code pendingEdit} — the approval itself is never rolled back.
|
|
||||||
*
|
|
||||||
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
|
|
||||||
* yet persisted by the caller at this point)
|
|
||||||
* @param client the client whose details were edited; used for display name and email
|
|
||||||
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
|
|
||||||
* {@code https://cbs.example.com}) used to build the tracking-pixel href
|
|
||||||
* @param tenantId the current tenant identifier, used to build the pixel URL
|
|
||||||
*/
|
|
||||||
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
|
|
||||||
}
|
|
||||||
-301
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.client.service;
|
|
||||||
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
|
|
||||||
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
|
|
||||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
|
|
||||||
* Checker. Records the tracking token, exact subject and exact body back onto the
|
|
||||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
|
|
||||||
* the entity after this method returns).
|
|
||||||
*
|
|
||||||
* <p>Template selection:
|
|
||||||
* <ul>
|
|
||||||
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
|
|
||||||
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
|
|
||||||
* {@code original_json}). Lists old→new values in a table.</li>
|
|
||||||
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
|
|
||||||
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
|
|
||||||
* approval transaction to roll back. The caller catches any exception from this service and logs it.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
|
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
|
|
||||||
|
|
||||||
private static final String BANK_NAME = "CBSNexora";
|
|
||||||
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
|
|
||||||
|
|
||||||
/** Contact fields that trigger Template A when they actually change. */
|
|
||||||
private static final String FIELD_MOBILE = "mobileNo";
|
|
||||||
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
|
|
||||||
private static final String FIELD_EMAIL = "emailAddress";
|
|
||||||
|
|
||||||
/** Human-readable labels for every field that can appear in the email body — mirrors the
|
|
||||||
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
|
|
||||||
private static final Map<String, String> FIELD_LABELS;
|
|
||||||
static {
|
|
||||||
FIELD_LABELS = new LinkedHashMap<>();
|
|
||||||
FIELD_LABELS.put("firstname", "First Name");
|
|
||||||
FIELD_LABELS.put("middlename", "Middle Name");
|
|
||||||
FIELD_LABELS.put("lastname", "Last Name");
|
|
||||||
FIELD_LABELS.put("fullname", "Full Name");
|
|
||||||
FIELD_LABELS.put("mobileNo", "Mobile Number");
|
|
||||||
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
|
|
||||||
FIELD_LABELS.put("emailAddress", "Email Address");
|
|
||||||
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
|
|
||||||
FIELD_LABELS.put("genderId", "Gender");
|
|
||||||
FIELD_LABELS.put("maritalStatusId", "Marital Status");
|
|
||||||
FIELD_LABELS.put("educationLevelId", "Education Level");
|
|
||||||
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
|
|
||||||
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
|
|
||||||
FIELD_LABELS.put("occupationId", "Occupation");
|
|
||||||
FIELD_LABELS.put("religionId", "Religion");
|
|
||||||
FIELD_LABELS.put("socialCategoryId", "Social Category");
|
|
||||||
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
|
|
||||||
FIELD_LABELS.put("nationalityId", "Nationality");
|
|
||||||
FIELD_LABELS.put("officeId", "Branch / Office");
|
|
||||||
FIELD_LABELS.put("staffId", "Relationship Manager");
|
|
||||||
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
|
|
||||||
FIELD_LABELS.put("legalFormId", "Legal Form");
|
|
||||||
FIELD_LABELS.put("salutationId", "Salutation");
|
|
||||||
}
|
|
||||||
|
|
||||||
private final EmailSenderService emailSenderService;
|
|
||||||
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
|
|
||||||
final String baseUrl, final String tenantId) {
|
|
||||||
|
|
||||||
final String toEmail = client.getEmailAddress();
|
|
||||||
if (toEmail == null || toEmail.isBlank()) {
|
|
||||||
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
|
|
||||||
client.getId(), pendingEdit.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final String token = UUID.randomUUID().toString();
|
|
||||||
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
|
|
||||||
|
|
||||||
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
|
|
||||||
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
|
|
||||||
|
|
||||||
final boolean isContactChange = detectContactChange(payload, original);
|
|
||||||
|
|
||||||
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
|
|
||||||
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
|
|
||||||
|
|
||||||
final String subject;
|
|
||||||
final String body;
|
|
||||||
if (isContactChange) {
|
|
||||||
subject = "Your contact details have been updated — " + BANK_NAME;
|
|
||||||
body = buildContactChangeBody(client.getDisplayName(), payload, original,
|
|
||||||
checkedDateFormatted, pixelUrl);
|
|
||||||
} else {
|
|
||||||
subject = "Your profile details have been updated — " + BANK_NAME;
|
|
||||||
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
|
|
||||||
checkedDateFormatted, pixelUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
|
|
||||||
|
|
||||||
if (sent) {
|
|
||||||
String fromEmail = "noreply@cbsnexora.in";
|
|
||||||
try {
|
|
||||||
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
|
|
||||||
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
|
|
||||||
fromEmail = smtp.getFromEmail();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingEdit.markEmailSent(token, subject, body, fromEmail);
|
|
||||||
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
|
|
||||||
} else {
|
|
||||||
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (final Exception e) {
|
|
||||||
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
|
|
||||||
pendingEdit.getId(), e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Template detection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if any of the three contact fields actually changed between
|
|
||||||
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
|
|
||||||
* falls back to false (Template B).
|
|
||||||
*/
|
|
||||||
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
|
|
||||||
if (original == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
|
||||||
final String payloadVal = stringOf(payload, field);
|
|
||||||
final String originalVal = stringOf(original, field);
|
|
||||||
// Only counts as a change when both sides have a value and they differ, OR when
|
|
||||||
// payload has a non-blank value and original didn't have the key at all.
|
|
||||||
if (payloadVal != null && !payloadVal.equals(originalVal)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Template A ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private String buildContactChangeBody(final String clientName, final JsonObject payload,
|
|
||||||
final JsonObject original, final String date, final String pixelUrl) {
|
|
||||||
|
|
||||||
final StringBuilder rows = new StringBuilder();
|
|
||||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
|
||||||
final String newVal = stringOf(payload, field);
|
|
||||||
final String oldVal = original != null ? stringOf(original, field) : null;
|
|
||||||
if (newVal == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (oldVal != null && oldVal.equals(newVal)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final String label = FIELD_LABELS.getOrDefault(field, field);
|
|
||||||
rows.append("<tr>")
|
|
||||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
|
|
||||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
|
|
||||||
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
|
|
||||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
|
|
||||||
.append(escHtml(newVal)).append("</td>")
|
|
||||||
.append("</tr>");
|
|
||||||
}
|
|
||||||
|
|
||||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
|
||||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
|
||||||
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
|
|
||||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
|
||||||
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
|
|
||||||
+ "<thead><tr>"
|
|
||||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
|
|
||||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
|
|
||||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
|
|
||||||
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
|
|
||||||
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
|
|
||||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
|
||||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
|
||||||
+ "</body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Template B ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
|
|
||||||
final JsonObject original, final String date, final String pixelUrl) {
|
|
||||||
|
|
||||||
final StringBuilder items = new StringBuilder();
|
|
||||||
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
|
|
||||||
final String key = entry.getKey();
|
|
||||||
// Skip internal/non-display keys
|
|
||||||
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final String payloadVal = stringOf(payload, key);
|
|
||||||
final String originalVal = original != null ? stringOf(original, key) : null;
|
|
||||||
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final String label = FIELD_LABELS.getOrDefault(key, key);
|
|
||||||
items.append("<li>").append(escHtml(label)).append("</li>");
|
|
||||||
}
|
|
||||||
final String listHtml = items.length() > 0
|
|
||||||
? "<ul style='padding-left:20px;'>" + items + "</ul>"
|
|
||||||
: "<p><em>No specific fields identified.</em></p>";
|
|
||||||
|
|
||||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
|
||||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
|
||||||
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
|
|
||||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
|
||||||
+ "<p>The following fields were changed:</p>"
|
|
||||||
+ listHtml
|
|
||||||
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
|
|
||||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
|
||||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
|
||||||
+ "</body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
|
|
||||||
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
||||||
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
|
|
||||||
// without requiring a tenant header (it accepts the token and commits in the default
|
|
||||||
// datasource context). However we embed it as a query param so future multi-tenant
|
|
||||||
// support is easy to add without changing the stored URL.
|
|
||||||
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonObject parseJson(final String json) {
|
|
||||||
if (json == null || json.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final JsonElement el = JsonParser.parseString(json);
|
|
||||||
return el.isJsonObject() ? el.getAsJsonObject() : null;
|
|
||||||
} catch (final Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String stringOf(final JsonObject obj, final String key) {
|
|
||||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final JsonElement el = obj.get(key);
|
|
||||||
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String escHtml(final String s) {
|
|
||||||
if (s == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
.replace("\"", """).replace("'", "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-9
@@ -26,15 +26,7 @@ public interface ClientEditWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
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);
|
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-24
@@ -33,8 +33,6 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -42,8 +40,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
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. */
|
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||||
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||||
|
|
||||||
@@ -52,7 +48,6 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||||
private final ClientWritePlatformService clientWritePlatformService;
|
private final ClientWritePlatformService clientWritePlatformService;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final ClientEditEmailService clientEditEmailService;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -97,7 +92,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
|
public CommandProcessingResult approveEdit(final Long clientId) {
|
||||||
final AppUser checker = this.context.authenticatedUser();
|
final AppUser checker = this.context.authenticatedUser();
|
||||||
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
@@ -110,7 +105,6 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
// 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
|
// 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.
|
// 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 JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||||
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||||
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||||
@@ -118,23 +112,6 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
pendingEdit.approve(checker);
|
pendingEdit.approve(checker);
|
||||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-27
@@ -120,11 +120,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
|||||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
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";
|
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
||||||
|
|
||||||
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||||
for (ClientFamilyMembersData fm : familyMembers) {
|
|
||||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
|
||||||
}
|
|
||||||
return familyMembers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -135,28 +131,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
|||||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||||
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
||||||
|
|
||||||
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||||
if (fm != null) {
|
|
||||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
|
||||||
}
|
|
||||||
return fm;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
|
|
||||||
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
|
|
||||||
"c.gender_cv_id as genderId, cv.code_value as gender " +
|
|
||||||
"from m_family_member_children c " +
|
|
||||||
"left join m_code_value cv on c.gender_cv_id = cv.id " +
|
|
||||||
"where c.family_member_id = ?";
|
|
||||||
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
|
|
||||||
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
|
|
||||||
.id(rs.getLong("id"))
|
|
||||||
.fullName(rs.getString("fullName"))
|
|
||||||
.genderId(JdbcSupport.getLong(rs, "genderId"))
|
|
||||||
.gender(rs.getString("gender"))
|
|
||||||
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
|
|
||||||
.build();
|
|
||||||
}, familyMemberId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
-41
@@ -44,7 +44,6 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
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.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -135,33 +134,6 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
|
|
||||||
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
|
||||||
if (childrenArray != null) {
|
|
||||||
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
|
|
||||||
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
|
|
||||||
for (JsonElement childElement : childrenArray) {
|
|
||||||
if (childElement.isJsonNull()) continue;
|
|
||||||
JsonObject childObj = childElement.getAsJsonObject();
|
|
||||||
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
|
|
||||||
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
|
|
||||||
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
|
|
||||||
LocalDate dob = null;
|
|
||||||
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
|
|
||||||
try {
|
|
||||||
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
LOG.warn("Could not parse dateOfBirth for child", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
|
|
||||||
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
familyMember.setChildren(children);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void validateFamilyRequirementsForActivation(final Client client) {
|
public void validateFamilyRequirementsForActivation(final Client client) {
|
||||||
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
||||||
@@ -286,10 +258,6 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.parameterExists("children")) {
|
|
||||||
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
||||||
@@ -453,11 +421,6 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
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);
|
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -614,10 +577,6 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||||
clientFamilyMember.getRelationship(), familyMemberId);
|
clientFamilyMember.getRelationship(), familyMemberId);
|
||||||
|
|
||||||
if (command.parameterExists("children")) {
|
|
||||||
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||||
|
|||||||
-9
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.service;
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.Page;
|
import org.apache.fineract.infrastructure.core.service.Page;
|
||||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||||
|
|
||||||
@@ -34,12 +33,4 @@ public interface ClientPendingEditReadPlatformService {
|
|||||||
*/
|
*/
|
||||||
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||||
Integer limit);
|
Integer limit);
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
|
|
||||||
* edit — only populated once the approval email has been sent. Both {@code clientId} and
|
|
||||||
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
|
|
||||||
* when the record does not exist.
|
|
||||||
*/
|
|
||||||
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-37
@@ -46,9 +46,7 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
||||||
pe.submitted_date AS submittedDate,
|
pe.submitted_date AS submittedDate,
|
||||||
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
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
|
FROM m_client_pending_edit pe
|
||||||
JOIN m_client c ON c.id = pe.client_id
|
JOIN m_client c ON c.id = pe.client_id
|
||||||
JOIN m_appuser maker ON maker.id = pe.maker_id
|
JOIN m_appuser maker ON maker.id = pe.maker_id
|
||||||
@@ -58,9 +56,6 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
||||||
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||||
final OffsetDateTime checkedDate = rs.getObject("checkedDate", 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() //
|
return ClientPendingEditData.builder() //
|
||||||
.id(rs.getLong("id")) //
|
.id(rs.getLong("id")) //
|
||||||
.clientId(rs.getLong("clientId")) //
|
.clientId(rs.getLong("clientId")) //
|
||||||
@@ -75,9 +70,6 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
.checkerName(rs.getString("checkerName")) //
|
.checkerName(rs.getString("checkerName")) //
|
||||||
.checkedDate(checkedDate) //
|
.checkedDate(checkedDate) //
|
||||||
.rejectReason(rs.getString("rejectReason")) //
|
.rejectReason(rs.getString("rejectReason")) //
|
||||||
.emailSent(emailSent) //
|
|
||||||
.emailSentAt(emailSentAt) //
|
|
||||||
.emailOpenedAt(emailOpenedAt) //
|
|
||||||
.build();
|
.build();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,32 +119,4 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
|
|
||||||
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
|
|
||||||
final String sql = """
|
|
||||||
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
|
|
||||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
|
||||||
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
|
|
||||||
c.email_address AS clientEmail
|
|
||||||
FROM m_client_pending_edit pe
|
|
||||||
JOIN m_client c ON c.id = pe.client_id
|
|
||||||
WHERE pe.id = ? AND pe.client_id = ?
|
|
||||||
""";
|
|
||||||
try {
|
|
||||||
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
|
||||||
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
|
||||||
result.put("emailSubject", rs.getString("emailSubject"));
|
|
||||||
result.put("emailBody", rs.getString("emailBody"));
|
|
||||||
result.put("emailSent", rs.getBoolean("emailSent"));
|
|
||||||
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
|
|
||||||
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
|
|
||||||
result.put("emailFrom", rs.getString("emailFrom"));
|
|
||||||
result.put("emailTo", rs.getString("clientEmail"));
|
|
||||||
return result;
|
|
||||||
}, pendingEditId, clientId);
|
|
||||||
} catch (final EmptyResultDataAccessException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-20
@@ -96,7 +96,6 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
|
|||||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -137,7 +136,6 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
private final CifNumberGenerator cifNumberGenerator;
|
private final CifNumberGenerator cifNumberGenerator;
|
||||||
private final ClientContactOtpService clientContactOtpService;
|
private final ClientContactOtpService clientContactOtpService;
|
||||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -790,27 +788,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
* client onboarded without a mobile number is having one set for the first time) — there is no
|
* 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.
|
* existing verified channel to prove possession of in that case.
|
||||||
*/
|
*/
|
||||||
private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
|
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
|
||||||
final String newValue) {
|
final String newValue) {
|
||||||
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
||||||
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
||||||
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
||||||
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
|
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(oldValue)) {
|
if (StringUtils.isNotBlank(oldValue)) {
|
||||||
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
|
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||||
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);
|
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
||||||
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
||||||
@@ -878,7 +862,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||||
final String oldValue = clientForUpdate.getMobileNo();
|
final String oldValue = clientForUpdate.getMobileNo();
|
||||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||||
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
||||||
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
oldMobileNoForNotification = oldValue;
|
oldMobileNoForNotification = oldValue;
|
||||||
@@ -895,7 +879,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||||
final String oldValue = clientForUpdate.getEmailAddress();
|
final String oldValue = clientForUpdate.getEmailAddress();
|
||||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||||
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
||||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
oldEmailAddressForNotification = oldValue;
|
oldEmailAddressForNotification = oldValue;
|
||||||
|
|||||||
+3
-18
@@ -19,12 +19,8 @@
|
|||||||
package org.apache.fineract.portfolio.client.service;
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.function.Function;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.data.KycRiskRuleData;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||||
@@ -37,18 +33,14 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
|
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
|
||||||
|
|
||||||
private final ClientRiskRuleRepository repository;
|
private final ClientRiskRuleRepository repository;
|
||||||
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<KycRiskRuleData> retrieveAllRules() {
|
public List<KycRiskRuleData> retrieveAllRules() {
|
||||||
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
|
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
|
||||||
final Map<Long, AmlTransactionRule> txnRulesBySourceId = this.amlTransactionRuleRepository.findAll().stream()
|
return rules.stream().map(this::mapToData).collect(Collectors.toList());
|
||||||
.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, final AmlTransactionRule txnRule) {
|
private KycRiskRuleData mapToData(final ClientRiskRule rule) {
|
||||||
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
||||||
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
||||||
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
|
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
|
||||||
@@ -75,14 +67,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
|||||||
rule.getBannedEntityMatch(),
|
rule.getBannedEntityMatch(),
|
||||||
rule.getWillfulDefaulter(),
|
rule.getWillfulDefaulter(),
|
||||||
rule.getEcgcSalFlag(),
|
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
|||||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
|||||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||||
|
|||||||
@@ -491,13 +491,4 @@
|
|||||||
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0438_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0439_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0444_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0445_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0446_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+24
-16
@@ -23,24 +23,32 @@
|
|||||||
|
|
||||||
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
|
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
|
||||||
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
|
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
|
||||||
by "INTEREST PAYABLE" (gl_code 9.2). -->
|
by "INTEREST PAYABLE" (gl_code 9.2).
|
||||||
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
|
|
||||||
|
parent_id is resolved by gl_code (not hardcoded) — the "OTHER LIABILITY" row's numeric id
|
||||||
|
is an auto-increment value that depends on each tenant's own insert history, so a
|
||||||
|
hardcoded id (previously 22) only happened to match the `default` tenant and broke FK
|
||||||
|
inserts on any tenant whose chart of accounts was seeded differently (e.g. `saga`), which
|
||||||
|
aborted that tenant's whole Liquibase run — and with it, the entire application startup.
|
||||||
|
runOnChange lets this corrected version safely re-validate on tenants where the old,
|
||||||
|
already-recorded version succeeded: the "gl_code 9.3 doesn't exist yet" precondition below
|
||||||
|
is already false there, so it's skipped rather than re-inserted. -->
|
||||||
|
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account" runOnChange="true">
|
||||||
<preConditions onFail="MARK_RAN">
|
<preConditions onFail="MARK_RAN">
|
||||||
<not>
|
<and>
|
||||||
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
|
<not>
|
||||||
</not>
|
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
|
||||||
|
</not>
|
||||||
|
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9'</sqlCheck>
|
||||||
|
</and>
|
||||||
</preConditions>
|
</preConditions>
|
||||||
<insert tableName="acc_gl_account">
|
<sql>
|
||||||
<column name="name" value="Sponsor DD Payable - SBI"/>
|
INSERT INTO acc_gl_account (name, gl_code, parent_id, hierarchy, account_usage, classification_enum, disabled, manual_journal_entries_allowed, description)
|
||||||
<column name="gl_code" value="9.3"/>
|
SELECT 'Sponsor DD Payable - SBI', '9.3', parent.id, '.', 1, 2, FALSE, TRUE,
|
||||||
<column name="parent_id" valueNumeric="22"/>
|
'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.'
|
||||||
<column name="hierarchy" value="."/>
|
FROM acc_gl_account parent
|
||||||
<column name="account_usage" valueNumeric="1"/>
|
WHERE parent.gl_code = '9';
|
||||||
<column name="classification_enum" valueNumeric="2"/>
|
</sql>
|
||||||
<column name="disabled" valueBoolean="false"/>
|
|
||||||
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
|
|
||||||
<column name="description" value="Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing."/>
|
|
||||||
</insert>
|
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|
||||||
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
|
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
|
||||||
|
|||||||
+20
-13
@@ -32,6 +32,13 @@
|
|||||||
Threshold numbers marked "illustrative default" mirror the bank's own Rules Parameter
|
Threshold numbers marked "illustrative default" mirror the bank's own Rules Parameter
|
||||||
screens where a configured value was available (Rules 1, 6, 10, 11, 12, 13); others are
|
screens where a configured value was available (Rules 1, 6, 10, 11, 12, 13); others are
|
||||||
reasonable starting points meant to be tuned via the AML rule administration UI.
|
reasonable starting points meant to be tuned via the AML rule administration UI.
|
||||||
|
|
||||||
|
order_position is only unique WITHIN 0430's own 221-row insert - it collides with the
|
||||||
|
older, differently-numbered seed rows from 0428_add_client_risk_rules.xml (e.g. both
|
||||||
|
insert a row at order_position 10 and 30), which made every subquery below ambiguous
|
||||||
|
("more than one row returned") and aborted the whole tenant migration. aml_check_status
|
||||||
|
is populated only on 0430's rows (0428's predate that column and are NULL there), so
|
||||||
|
pairing it with order_position uniquely targets the intended 0430 row.
|
||||||
-->
|
-->
|
||||||
<changeSet author="fineract" id="0434-1">
|
<changeSet author="fineract" id="0434-1">
|
||||||
<preConditions onFail="MARK_RAN">
|
<preConditions onFail="MARK_RAN">
|
||||||
@@ -42,19 +49,19 @@
|
|||||||
VALUES
|
VALUES
|
||||||
('CTR_THRESHOLD_10L', 'Mandatory CTR reporting threshold - single cash transaction of INR 10,00,000 or more', 'ALL', 1000000, 'THRESHOLD', null, null, true),
|
('CTR_THRESHOLD_10L', 'Mandatory CTR reporting threshold - single cash transaction of INR 10,00,000 or more', 'ALL', 1000000, 'THRESHOLD', null, null, true),
|
||||||
('STRUCTURED_CTR_10L_MONTHLY', 'Connected cash transactions summing to INR 10,00,000 or more within a calendar month (CTR aggregation rule)', 'ALL', 1000000, 'STRUCTURING', 30, null, true),
|
('STRUCTURED_CTR_10L_MONTHLY', 'Connected cash transactions summing to INR 10,00,000 or more within a calendar month (CTR aggregation rule)', 'ALL', 1000000, 'STRUCTURING', 30, null, true),
|
||||||
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 LIMIT 1), true),
|
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 AND aml_check_status IS NOT NULL), true),
|
||||||
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 LIMIT 1), true),
|
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 AND aml_check_status IS NOT NULL), true),
|
||||||
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 LIMIT 1), true),
|
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 AND aml_check_status IS NOT NULL), true),
|
||||||
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 LIMIT 1), true),
|
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 AND aml_check_status IS NOT NULL), true),
|
||||||
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 LIMIT 1), true),
|
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 AND aml_check_status IS NOT NULL), true),
|
||||||
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 LIMIT 1), true),
|
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 AND aml_check_status IS NOT NULL), true),
|
||||||
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 LIMIT 1), true),
|
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 AND aml_check_status IS NOT NULL), true),
|
||||||
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 LIMIT 1), true),
|
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 AND aml_check_status IS NOT NULL), true),
|
||||||
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 LIMIT 1), true),
|
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 AND aml_check_status IS NOT NULL), true),
|
||||||
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 LIMIT 1), true),
|
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 AND aml_check_status IS NOT NULL), true),
|
||||||
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 LIMIT 1), true),
|
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 AND aml_check_status IS NOT NULL), true),
|
||||||
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 LIMIT 1), true),
|
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 AND aml_check_status IS NOT NULL), true),
|
||||||
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 LIMIT 1), true);
|
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 AND aml_check_status IS NOT NULL), true);
|
||||||
</sql>
|
</sql>
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|
||||||
|
|||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<!-- Optional risk-category-tiered thresholds. Null on both columns means the rule is not
|
|
||||||
tiered and threshold_amount alone is used (unchanged behaviour for every pre-existing rule). -->
|
|
||||||
<changeSet author="fineract" id="0438-1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_medium_risk"/></not>
|
|
||||||
</preConditions>
|
|
||||||
<addColumn tableName="m_aml_transaction_rule">
|
|
||||||
<column name="threshold_amount_medium_risk" type="DECIMAL(19,6)">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="fineract" id="0438-2">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_high_risk"/></not>
|
|
||||||
</preConditions>
|
|
||||||
<addColumn tableName="m_aml_transaction_rule">
|
|
||||||
<column name="threshold_amount_high_risk" type="DECIMAL(19,6)">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!-- RTGS monitoring: Medium-risk clients are flagged at INR 5,00,000, High-risk at INR 20,00,000.
|
|
||||||
threshold_amount (2,000,000) remains the fallback for Low-risk/unclassified clients. -->
|
|
||||||
<changeSet author="fineract" id="0438-3">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'RTGS_HIGH_VALUE' AND threshold_amount_medium_risk IS NOT NULL</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
UPDATE m_aml_transaction_rule
|
|
||||||
SET threshold_amount_medium_risk = 500000,
|
|
||||||
threshold_amount_high_risk = 2000000
|
|
||||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!-- Rule 1 from the legacy Rules Parameter catalog: "Maintaining a low or overdrawn balance
|
|
||||||
with high activity" (order_position 1). The legacy screen reports a single INR 10,00,000
|
|
||||||
figure across three distinct parameters (average balance period, minimum balance, max
|
|
||||||
transaction count), which does not correspond to any threshold/window shape this catalog
|
|
||||||
currently evaluates (THRESHOLD/STRUCTURING/VELOCITY all key off a transaction AMOUNT, not
|
|
||||||
an account balance level). Seeded here for catalog/display parity with m_client_risk_rule,
|
|
||||||
with is_active = false so it is never picked up by AmlRuleEvaluationServiceImpl until a
|
|
||||||
balance-based rule type is built. -->
|
|
||||||
<changeSet author="fineract" id="0438-4">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY'</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_aml_transaction_rule
|
|
||||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
|
|
||||||
VALUES
|
|
||||||
('LOW_BALANCE_HIGH_ACTIVITY', 'Maintaining a low or overdrawn balance with high activity', 'SAVINGS', 1000000, 'THRESHOLD', null,
|
|
||||||
(SELECT id FROM m_client_risk_rule WHERE order_position = 1 AND aml_check_status IS NOT NULL), false);
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<changeSet author="fineract" id="0439-1">
|
|
||||||
<validCheckSum>*</validCheckSum>
|
|
||||||
<sql>
|
|
||||||
-- 1. High Risk Occupations (Real Estate, Importer/Exporter, Commission Agent, Financial Advisor)
|
|
||||||
UPDATE m_client_risk_rule
|
|
||||||
SET min_monthly_income = 200000.0,
|
|
||||||
min_annual_income = 2400000.0
|
|
||||||
WHERE order_position IN (20, 30, 40, 50);
|
|
||||||
|
|
||||||
-- 2. Medium Risk Occupations
|
|
||||||
UPDATE m_client_risk_rule
|
|
||||||
SET min_monthly_income = 50000.0,
|
|
||||||
max_monthly_income = 200000.0,
|
|
||||||
min_annual_income = 600000.0,
|
|
||||||
max_annual_income = 2400000.0
|
|
||||||
WHERE order_position BETWEEN 90 AND 240;
|
|
||||||
|
|
||||||
-- 3. Low Risk Occupations
|
|
||||||
UPDATE m_client_risk_rule
|
|
||||||
SET min_monthly_income = 0.0,
|
|
||||||
max_monthly_income = 50000.0,
|
|
||||||
min_annual_income = 0.0,
|
|
||||||
max_annual_income = 600000.0
|
|
||||||
WHERE order_position BETWEEN 280 AND 450;
|
|
||||||
|
|
||||||
-- 4. Set all remaining NULL values to 0.00
|
|
||||||
UPDATE m_client_risk_rule SET min_monthly_income = 0.0 WHERE min_monthly_income IS NULL;
|
|
||||||
UPDATE m_client_risk_rule SET max_monthly_income = 0.0 WHERE max_monthly_income IS NULL;
|
|
||||||
UPDATE m_client_risk_rule SET min_annual_income = 0.0 WHERE min_annual_income IS NULL;
|
|
||||||
UPDATE m_client_risk_rule SET max_annual_income = 0.0 WHERE max_annual_income IS NULL;
|
|
||||||
UPDATE m_client_risk_rule SET min_annual_turnover = 0.0 WHERE min_annual_turnover IS NULL;
|
|
||||||
UPDATE m_client_risk_rule SET max_annual_turnover = 0.0 WHERE max_annual_turnover IS NULL;
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<changeSet author="fineract" id="0440-1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH'</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_client (
|
|
||||||
account_no, external_id, status_enum, office_id, display_name,
|
|
||||||
fullname, is_staff, activation_date, office_joining_date,
|
|
||||||
submittedon_date, legal_form_enum,
|
|
||||||
created_on_utc, created_by,
|
|
||||||
last_modified_on_utc, last_modified_by,
|
|
||||||
cif_number
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
'ACC0000002', 'CLIENT_RAJESH', 300, 1, 'Rajesh Kumar',
|
|
||||||
'Rajesh Kumar', false, CURRENT_DATE, CURRENT_DATE,
|
|
||||||
CURRENT_DATE, 1,
|
|
||||||
NOW(), 1,
|
|
||||||
NOW(), 1,
|
|
||||||
'CIF_RAJESH'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'ACC0000003', 'CLIENT_SONIA', 300, 1, 'Sonia Sharma',
|
|
||||||
'Sonia Sharma', false, CURRENT_DATE, CURRENT_DATE,
|
|
||||||
CURRENT_DATE, 1,
|
|
||||||
NOW(), 1,
|
|
||||||
NOW(), 1,
|
|
||||||
'CIF_SONIA'
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'ACC0000004', 'CLIENT_VIKRAM', 300, 1, 'Vikram Malhotra',
|
|
||||||
'Vikram Malhotra', false, CURRENT_DATE, CURRENT_DATE,
|
|
||||||
CURRENT_DATE, 1,
|
|
||||||
NOW(), 1,
|
|
||||||
NOW(), 1,
|
|
||||||
'CIF_VIKRAM'
|
|
||||||
);
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="fineract" id="0440-2">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">
|
|
||||||
SELECT COUNT(*) FROM m_aml_alert a
|
|
||||||
JOIN m_client c ON a.client_id = c.id
|
|
||||||
WHERE c.external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA', 'CLIENT_VIKRAM')
|
|
||||||
</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_aml_alert (
|
|
||||||
transaction_type, transaction_id, client_id, rule_id, amount, currency_code,
|
|
||||||
raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
'SAVINGS', 1001,
|
|
||||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'),
|
|
||||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'),
|
|
||||||
3500000.000000, 'INR',
|
|
||||||
NOW(), 'RAISED',
|
|
||||||
1, NOW(), 1, NOW(), 0
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'SAVINGS', 1002,
|
|
||||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'),
|
|
||||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'),
|
|
||||||
995000.000000, 'INR',
|
|
||||||
NOW(), 'RAISED',
|
|
||||||
1, NOW(), 1, NOW(), 0
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'SAVINGS', 1003,
|
|
||||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_VIKRAM'),
|
|
||||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'PEP_ACCOUNT_MONITORING'),
|
|
||||||
75000.000000, 'INR',
|
|
||||||
NOW(), 'RAISED',
|
|
||||||
1, NOW(), 1, NOW(), 0
|
|
||||||
);
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Declarative min/max band for each AML transaction rule, used by the AML Rules
|
|
||||||
Master screen.
|
|
||||||
|
|
||||||
These are deliberately NOT the existing threshold_amount_medium_risk /
|
|
||||||
threshold_amount_high_risk columns: those are live alerting overrides consumed by
|
|
||||||
AmlRuleEvaluationServiceImpl.resolveThreshold(), and populating them purely to fill
|
|
||||||
a display column would change which transactions raise alerts. These new columns
|
|
||||||
are read-only documentation of the band a rule covers and never affect matching.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="0441-1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<not>
|
|
||||||
<columnExists tableName="m_aml_transaction_rule" columnName="min_limit_amount"/>
|
|
||||||
</not>
|
|
||||||
</preConditions>
|
|
||||||
<addColumn tableName="m_aml_transaction_rule">
|
|
||||||
<column name="min_limit_amount" type="DECIMAL(19,6)"/>
|
|
||||||
<column name="max_limit_amount" type="DECIMAL(19,6)"/>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Band values. The minimum is the amount at which the rule starts to bite; the maximum
|
|
||||||
is the amount beyond which the exposure is treated as materially more serious and
|
|
||||||
escalated. A NULL maximum means the rule is genuinely open-ended.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="0441-2">
|
|
||||||
<sql>
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
|
||||||
WHERE rule_code = 'CTR_THRESHOLD_10L';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
|
||||||
WHERE rule_code = 'STRUCTURED_CTR_10L_MONTHLY';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2500000, max_limit_amount = 5000000
|
|
||||||
WHERE rule_code = 'LARGE_CASH_TXN_25L';
|
|
||||||
-- Structuring rules watch the band immediately BELOW the reporting threshold.
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 700000, max_limit_amount = 1000000
|
|
||||||
WHERE rule_code = 'STRUCTURING_BELOW_10L';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 35000, max_limit_amount = 50000
|
|
||||||
WHERE rule_code = 'STRUCTURING_BELOW_50K';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 900000, max_limit_amount = 1000000
|
|
||||||
WHERE rule_code = 'FREQUENT_CASH_NEAR_10L';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
|
||||||
WHERE rule_code = 'FREQUENT_LOW_CASH_DEPOSIT';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
|
||||||
WHERE rule_code = 'FREQUENT_LOW_CASH_WITHDRAWAL';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 2000000
|
|
||||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2000000, max_limit_amount = 5000000
|
|
||||||
WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
|
||||||
WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 500000
|
|
||||||
WHERE rule_code = 'PEP_ACCOUNT_MONITORING';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 1000000
|
|
||||||
WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 200000, max_limit_amount = 500000
|
|
||||||
WHERE rule_code = 'LOAN_REPAYMENT_IN_CASH';
|
|
||||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
|
||||||
WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY';
|
|
||||||
-- RAPID_MOVEMENT_IN_ACCOUNTS is a pure velocity rule with no monetary threshold,
|
|
||||||
-- so it is intentionally left without a band.
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The three High-risk income rules were seeded with a floor but a zero ceiling, which the
|
|
||||||
UI cannot distinguish from "not captured". Normalise the zeros to NULL so an absent
|
|
||||||
ceiling reads as genuinely open-ended, and give the medium bands their missing floors.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="0441-3">
|
|
||||||
<sql>
|
|
||||||
UPDATE m_client_risk_rule SET max_monthly_income = NULL WHERE max_monthly_income = 0;
|
|
||||||
UPDATE m_client_risk_rule SET max_annual_income = NULL WHERE max_annual_income = 0;
|
|
||||||
UPDATE m_client_risk_rule SET max_annual_turnover = NULL WHERE max_annual_turnover = 0;
|
|
||||||
UPDATE m_client_risk_rule SET min_monthly_income = NULL WHERE min_monthly_income = 0;
|
|
||||||
UPDATE m_client_risk_rule SET min_annual_income = NULL WHERE min_annual_income = 0;
|
|
||||||
UPDATE m_client_risk_rule SET min_annual_turnover = NULL WHERE min_annual_turnover = 0;
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Branch offices under Head Office.
|
|
||||||
|
|
||||||
Until now the tenant had a single office, so "see only my branch" had nothing to
|
|
||||||
scope to. external_id carries the branch code used by the General Ledger and the
|
|
||||||
RBI return screens, which is what lets a logged-in user's office be resolved to a
|
|
||||||
branch without matching on display names.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="0442-1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006')</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_office (parent_id, hierarchy, external_id, name, opening_date)
|
|
||||||
VALUES
|
|
||||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.2.', '0002', 'Andheri (E)', DATE '2018-04-01'),
|
|
||||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.3.', '0003', 'Bandra (W)', DATE '2018-04-01'),
|
|
||||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.4.', '0004', 'Borivali', DATE '2019-07-01'),
|
|
||||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.5.', '0005', 'Dadar', DATE '2020-01-02'),
|
|
||||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.6.', '0006', 'Thane', DATE '2021-06-01');
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!-- Head Office carries the branch code the GL ledger uses for it. -->
|
|
||||||
<changeSet author="fineract" id="0442-2">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id = 'HO'</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.';
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
m_aml_alert.rule_id is NOT NULL, so an alert a user raises by hand from the ledger still
|
|
||||||
needs a rule to hang off. MANUAL_REFERRAL is that rule.
|
|
||||||
|
|
||||||
It is seeded with is_active = false ON PURPOSE. AmlRuleEvaluationServiceImpl selects
|
|
||||||
candidates with findByActiveTrueAndAppliesTo, so an active ALL-scoped rule with a null
|
|
||||||
threshold would be evaluated against every live transaction. Inactive keeps it out of the
|
|
||||||
engine entirely; the referral service loads it by rule_code instead.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="0443-1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL'</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_aml_transaction_rule (rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
|
|
||||||
VALUES ('MANUAL_REFERRAL', 'Marked suspicious by a user from the general ledger - no automated rule fired', 'ALL', null, 'THRESHOLD', null, null, false);
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
|
||||||
|
|
||||||
<!-- Email confirmation tracking for the Client edit approval workflow.
|
|
||||||
When a Clerk approves a pending client edit, a confirmation email is sent to the
|
|
||||||
client's registered email address. These six columns record the outcome of that send
|
|
||||||
and allow staff to see whether the client has opened the email (via a 1x1 tracking
|
|
||||||
pixel whose URL embeds the UUID email_tracking_token).
|
|
||||||
|
|
||||||
email_subject / email_body store the exact content that was sent so a "View Email"
|
|
||||||
dialog can show it later without regenerating the template.
|
|
||||||
|
|
||||||
email_tracking_token is a random UUID generated at send time — looked up directly by
|
|
||||||
the public pixel endpoint (GET /api/*/clients/edit/pixel/{token}) so the endpoint
|
|
||||||
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-1-add-email-tracking-token">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_tracking_token" type="VARCHAR(36)">
|
|
||||||
<constraints nullable="true" unique="true"
|
|
||||||
uniqueConstraintName="uq_client_pending_edit_email_token"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-2-add-email-sent">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-3-add-email-sent-at">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-4-add-email-opened-at">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-5-add-email-subject">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_subject" type="VARCHAR(300)">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<changeSet author="CBS" id="0444-6-add-email-body">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_body" type="TEXT">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
|
||||||
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
|
|
||||||
<addColumn tableName="m_client_pending_edit">
|
|
||||||
<column name="email_from" type="VARCHAR(255)"/>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
</databaseChangeLog>
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
|
||||||
<changeSet id="1" author="fineract">
|
|
||||||
<createTable tableName="m_family_member_children">
|
|
||||||
<column name="id" type="BIGINT" autoIncrement="true">
|
|
||||||
<constraints primaryKey="true" nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="family_member_id" type="BIGINT">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="full_name" type="VARCHAR(255)">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="gender_cv_id" type="BIGINT">
|
|
||||||
<constraints nullable="false"/>
|
|
||||||
</column>
|
|
||||||
<column name="date_of_birth" type="DATE">
|
|
||||||
<constraints nullable="true"/>
|
|
||||||
</column>
|
|
||||||
</createTable>
|
|
||||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="family_member_id" constraintName="fk_family_member_children_family_member_id" referencedTableName="m_family_members" referencedColumnNames="id"/>
|
|
||||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="gender_cv_id" constraintName="fk_family_member_children_gender_cv_id" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
|
||||||
</changeSet>
|
|
||||||
</databaseChangeLog>
|
|
||||||
-1
@@ -116,7 +116,6 @@
|
|||||||
<class>org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat</class>
|
<class>org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat</class>
|
||||||
|
|
||||||
<!-- Client Module Entities -->
|
<!-- Client Module Entities -->
|
||||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren</class>
|
|
||||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMembers</class>
|
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMembers</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.ClientNonPerson</class>
|
<class>org.apache.fineract.portfolio.client.domain.ClientNonPerson</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.ClientTransferDetails</class>
|
<class>org.apache.fineract.portfolio.client.domain.ClientTransferDetails</class>
|
||||||
|
|||||||
-1593
File diff suppressed because it is too large
Load Diff
-2777
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user