Compare commits
5
Commits
baf30cff68
..
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9aa29fb35 | ||
|
|
84f8aa2274 | ||
|
|
7773a9e9c6 | ||
|
|
c890faee78 | ||
|
|
abc7eb22a2 |
+6
@@ -35,4 +35,10 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
long countByClientId(Long clientId);
|
||||
|
||||
/**
|
||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
*/
|
||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||
}
|
||||
|
||||
+36
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
|
||||
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmount;
|
||||
|
||||
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
|
||||
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
|
||||
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmountMediumRisk;
|
||||
|
||||
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmountHighRisk;
|
||||
|
||||
/**
|
||||
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
|
||||
* descriptive: unlike the tiered columns above these never take part in matching, so
|
||||
* changing them cannot alter which transactions raise an alert. A null maximum means
|
||||
* the rule is genuinely open-ended.
|
||||
*/
|
||||
@Column(name = "min_limit_amount", precision = 19, scale = 6)
|
||||
private BigDecimal minLimitAmount;
|
||||
|
||||
@Column(name = "max_limit_amount", precision = 19, scale = 6)
|
||||
private BigDecimal maxLimitAmount;
|
||||
|
||||
@Column(name = "rule_type", nullable = false, length = 20)
|
||||
private String ruleType;
|
||||
|
||||
@@ -86,6 +106,22 @@ public class AmlTransactionRule {
|
||||
return thresholdAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getThresholdAmountMediumRisk() {
|
||||
return thresholdAmountMediumRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getThresholdAmountHighRisk() {
|
||||
return thresholdAmountHighRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getMinLimitAmount() {
|
||||
return minLimitAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getMaxLimitAmount() {
|
||||
return maxLimitAmount;
|
||||
}
|
||||
|
||||
public AmlRuleType getRuleType() {
|
||||
return AmlRuleType.valueOf(ruleType);
|
||||
}
|
||||
|
||||
+8
@@ -19,9 +19,17 @@
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||
|
||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
||||
|
||||
/**
|
||||
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
|
||||
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
|
||||
* by hand from the ledger.
|
||||
*/
|
||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class ClientFamilyMemberChildData implements Serializable {
|
||||
private final Long id;
|
||||
private final String fullName;
|
||||
private final Long genderId;
|
||||
private final String gender;
|
||||
private final LocalDate dateOfBirth;
|
||||
}
|
||||
+3
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
|
||||
private final LocalDate marriageDate;
|
||||
private final String marriageAuthority;
|
||||
|
||||
@lombok.Setter
|
||||
private java.util.List<ClientFamilyMemberChildData> children;
|
||||
|
||||
// template holder
|
||||
private final Collection<CodeValueData> relationshipIdOptions;
|
||||
private final Collection<CodeValueData> genderIdOptions;
|
||||
|
||||
-60
@@ -241,18 +241,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "remarks", length = 500)
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "is_exception", nullable = false)
|
||||
private Boolean isException;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Column(name = "exception_resolved", nullable = false)
|
||||
private Boolean exceptionResolved;
|
||||
|
||||
@Column(name = "exception_resolved_on")
|
||||
private LocalDate exceptionResolvedOn;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected ClientKyc() {}
|
||||
@@ -273,8 +261,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.gstinVerified = false;
|
||||
this.cinVerified = false;
|
||||
this.dinVerified = false;
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
}
|
||||
|
||||
@@ -339,13 +325,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("form15Type"), null);
|
||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form15FY"), null);
|
||||
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
@@ -419,13 +398,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
@@ -823,38 +795,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public void setIsException(Boolean isException) {
|
||||
this.isException = isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public void setExceptionReason(String exceptionReason) {
|
||||
this.exceptionReason = exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
|
||||
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public LocalDate getExceptionResolvedOn() {
|
||||
return exceptionResolvedOn;
|
||||
}
|
||||
|
||||
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
|
||||
-204
@@ -1,204 +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.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One customer-outreach attempt against an open KYC exception.
|
||||
*
|
||||
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||
*
|
||||
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||
* browser.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc_contact_log")
|
||||
public class ClientKycContactLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||
@Column(name = "kyc_id")
|
||||
private Long kycId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", length = 20, nullable = false)
|
||||
private KycContactChannel channel;
|
||||
|
||||
@Column(name = "notes", length = 1000, nullable = false)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "officer_name", length = 200)
|
||||
private String officerName;
|
||||
|
||||
@Column(name = "logged_by_user_id")
|
||||
private Long loggedByUserId;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||
private KycContactDeliveryStatus deliveryStatus;
|
||||
|
||||
@Column(name = "destination_masked", length = 120)
|
||||
private String destinationMasked;
|
||||
|
||||
@Column(name = "provider", length = 40)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "provider_reference", length = 120)
|
||||
private String providerReference;
|
||||
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "message_body", length = 2000)
|
||||
private String messageBody;
|
||||
|
||||
@Column(name = "contacted_at", nullable = false)
|
||||
private LocalDateTime contactedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
protected ClientKycContactLog() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||
this.client = client;
|
||||
this.kycId = kycId;
|
||||
this.channel = channel;
|
||||
this.notes = notes;
|
||||
this.officerName = officerName;
|
||||
this.loggedByUserId = loggedByUserId;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.contactedAt = contactedAt;
|
||||
this.createdAt = contactedAt;
|
||||
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||
* persisted — the entity is append-only afterwards.
|
||||
*/
|
||||
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||
final String providerReference, final String failureReason, final String messageBody) {
|
||||
this.deliveryStatus = status;
|
||||
this.destinationMasked = destinationMasked;
|
||||
this.provider = provider;
|
||||
this.providerReference = providerReference;
|
||||
this.failureReason = truncate(failureReason, 500);
|
||||
this.messageBody = truncate(messageBody, 2000);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public Long getKycId() {
|
||||
return this.kycId;
|
||||
}
|
||||
|
||||
public KycContactChannel getChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
public String getOfficerName() {
|
||||
return this.officerName;
|
||||
}
|
||||
|
||||
public Long getLoggedByUserId() {
|
||||
return this.loggedByUserId;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return this.exceptionReason;
|
||||
}
|
||||
|
||||
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||
return this.deliveryStatus;
|
||||
}
|
||||
|
||||
public String getDestinationMasked() {
|
||||
return this.destinationMasked;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public String getProviderReference() {
|
||||
return this.providerReference;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
public String getMessageBody() {
|
||||
return this.messageBody;
|
||||
}
|
||||
|
||||
public LocalDateTime getContactedAt() {
|
||||
return this.contactedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return this.createdAt;
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +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 java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
*/
|
||||
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||
}
|
||||
-12
@@ -33,18 +33,6 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||
|
||||
/**
|
||||
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||
*
|
||||
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||
* every resolved record invisible in both tabs the moment it was resolved.
|
||||
*/
|
||||
List<ClientKyc> findByExceptionResolvedTrue();
|
||||
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||
|
||||
|
||||
-91
@@ -1,91 +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;
|
||||
|
||||
/**
|
||||
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||
* KYC exception.
|
||||
*
|
||||
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||
* <ul>
|
||||
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||
* down.</li>
|
||||
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||
* actually transmit a notice to the customer at save time.</li>
|
||||
* </ul>
|
||||
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||
* received, so every call site branches on this flag rather than on the channel name.
|
||||
*/
|
||||
public enum KycContactChannel {
|
||||
|
||||
PHONE_CALL("Phone Call", false),
|
||||
SMS("SMS", true),
|
||||
EMAIL("Email", true),
|
||||
WHATSAPP("WhatsApp Message", true),
|
||||
BRANCH_VISIT("In-Branch Visit", false),
|
||||
FIELD_VISIT("Agent Field Visit", false);
|
||||
|
||||
private final String displayLabel;
|
||||
private final boolean platformSent;
|
||||
|
||||
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||
this.displayLabel = displayLabel;
|
||||
this.platformSent = platformSent;
|
||||
}
|
||||
|
||||
public String getDisplayLabel() {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||
public boolean isPlatformSent() {
|
||||
return this.platformSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||
* labels describe the *purpose* of the message, not a different channel.
|
||||
*
|
||||
* @return the matching channel, or {@code null} when nothing matches.
|
||||
*/
|
||||
public static KycContactChannel fromString(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String normalised = raw.trim().toUpperCase();
|
||||
for (final KycContactChannel channel : values()) {
|
||||
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||
KycContactChannel best = null;
|
||||
for (final KycContactChannel channel : values()) {
|
||||
final String label = channel.displayLabel.toUpperCase();
|
||||
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||
best = channel;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +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;
|
||||
|
||||
/**
|
||||
* Outcome of a platform-sent KYC contact notice.
|
||||
*
|
||||
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||
*/
|
||||
public enum KycContactDeliveryStatus {
|
||||
|
||||
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||
NOT_APPLICABLE,
|
||||
|
||||
/** The gateway accepted the message for delivery. */
|
||||
SENT,
|
||||
|
||||
/** The gateway was reached but rejected the message, or the call errored. */
|
||||
FAILED,
|
||||
|
||||
/** The customer has no mobile number / email address on file for this channel. */
|
||||
SKIPPED_NO_DESTINATION,
|
||||
|
||||
/** No gateway is configured for this channel in this deployment. */
|
||||
PROVIDER_NOT_CONFIGURED;
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||
// Client edit approval email open-tracking pixel — hit by the client's email client
|
||||
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
||||
// path is the only capability credential; the endpoint always returns a GIF so
|
||||
// token validity is not leaked via different HTTP responses.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
|
||||
+1
-12
@@ -102,23 +102,12 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
String shareEntityType = "share";
|
||||
|
||||
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
|
||||
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
|
||||
String redeemresolutionnumber_paramname = "resolutionNumber";
|
||||
|
||||
String redeemmeetingdate_paramname = "meetingDate";
|
||||
|
||||
/** Loan this share account is held as margin/security against — optional, set via the
|
||||
* account update command. When present, a redeem request is checked against the loan's
|
||||
* outstanding balance. */
|
||||
String linkedloanid_paramname = "linkedLoanId";
|
||||
|
||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
|
||||
numberofshares_paramname, purchasedprice_paramname)));
|
||||
|
||||
}
|
||||
|
||||
+28
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/alerts")
|
||||
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlAlertReadPlatformService readPlatformService;
|
||||
private final AmlAlertWritePlatformService writePlatformService;
|
||||
private final JournalEntryReferralService referralService;
|
||||
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
||||
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
|
||||
.orElse(null);
|
||||
return this.toApiJsonSerializer.serialize(updated);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("manual-referrals")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List ledger entries marked by a user",
|
||||
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
|
||||
public String retrieveManualReferrals() {
|
||||
this.context.authenticatedUser();
|
||||
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("manual-referral")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mark a ledger entry cleared or suspicious",
|
||||
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
|
||||
public String referJournalEntry(final ManualReferralRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
|
||||
request.getNotes());
|
||||
return this.referralJsonSerializer.serialize(referral);
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.api;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
|
||||
*
|
||||
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
|
||||
* no report) or ESCALATE (raises an STR case for the Principal Officer).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ManualReferralRequest {
|
||||
|
||||
private Long journalEntryId;
|
||||
private String disposition;
|
||||
private String notes;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
|
||||
*
|
||||
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
|
||||
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
|
||||
*/
|
||||
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
|
||||
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
|
||||
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
|
||||
+32
-3
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,6 +28,8 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -35,8 +38,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
|
||||
private static final int MEDIUM_RISK = 2;
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -56,20 +63,42 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmount() == null) {
|
||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||
if (threshold == null) {
|
||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
||||
return false;
|
||||
}
|
||||
switch (rule.getRuleType()) {
|
||||
case THRESHOLD:
|
||||
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
return context.getAmount().compareTo(threshold) >= 0;
|
||||
case STRUCTURING:
|
||||
case VELOCITY:
|
||||
return context.getWindowAggregateAmount() != null
|
||||
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
|
||||
* clients.
|
||||
*/
|
||||
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
|
||||
.orElse(null);
|
||||
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
|
||||
return rule.getThresholdAmountHighRisk();
|
||||
}
|
||||
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
|
||||
return rule.getThresholdAmountMediumRisk();
|
||||
}
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
|
||||
/**
|
||||
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
|
||||
*
|
||||
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
|
||||
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
|
||||
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
|
||||
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
|
||||
*/
|
||||
public interface JournalEntryReferralService {
|
||||
|
||||
/** disposition: WHITELIST | ESCALATE. */
|
||||
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
|
||||
|
||||
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
|
||||
List<ManualReferralData> retrieveAll();
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
|
||||
|
||||
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
|
||||
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlStrCaseRepository strCaseRepository;
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||
|
||||
/**
|
||||
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
|
||||
* posted against.
|
||||
*
|
||||
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
|
||||
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
|
||||
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
|
||||
* a savings-type entity may be either a savings or a current account, which live in separate tables.
|
||||
*
|
||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||
*/
|
||||
private static final String RESOLVE_SQL = """
|
||||
SELECT je.amount AS amount,
|
||||
je.currency_code AS currencyCode,
|
||||
je.savings_transaction_id AS savingsTxnId,
|
||||
je.loan_transaction_id AS loanTxnId,
|
||||
je.entity_type_enum AS entityType,
|
||||
COALESCE(
|
||||
esa.client_id,
|
||||
eca.client_id,
|
||||
eloan.client_id,
|
||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||
sa.client_id,
|
||||
l.client_id,
|
||||
ct.client_id
|
||||
) AS clientId
|
||||
FROM acc_gl_journal_entry je
|
||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
|
||||
LEFT JOIN m_loan l ON l.id = lt.loan_id
|
||||
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
|
||||
WHERE je.id = ?
|
||||
""";
|
||||
|
||||
private static final String LIST_SQL = """
|
||||
SELECT a.id AS alertId,
|
||||
a.transaction_id AS journalEntryId,
|
||||
a.client_id AS clientId,
|
||||
c.display_name AS clientDisplayName,
|
||||
a.amount AS amount,
|
||||
a.currency_code AS currencyCode,
|
||||
a.raised_on AS raisedOn,
|
||||
a.status AS status,
|
||||
a.disposition AS disposition,
|
||||
u.username AS reviewedByUsername,
|
||||
a.review_notes AS reviewNotes,
|
||||
s.id AS strCaseId
|
||||
FROM m_aml_alert a
|
||||
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
|
||||
LEFT JOIN m_client c ON c.id = a.client_id
|
||||
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
|
||||
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
|
||||
ORDER BY a.raised_on DESC
|
||||
""";
|
||||
|
||||
@Override
|
||||
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
|
||||
if (journalEntryId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
|
||||
"A journal entry must be given to refer.");
|
||||
}
|
||||
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
|
||||
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
|
||||
"Disposition must be WHITELIST or ESCALATE.", disposition);
|
||||
}
|
||||
|
||||
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
|
||||
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
|
||||
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
|
||||
|
||||
// Marking the same entry twice would put two cases in front of the Principal Officer for
|
||||
// one transaction, so the first mark stands until it is explicitly reworked.
|
||||
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
|
||||
"This ledger entry has already been marked.", journalEntryId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = loadEntry(journalEntryId);
|
||||
final Number clientIdValue = (Number) row.get("clientId");
|
||||
if (clientIdValue == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
|
||||
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
|
||||
}
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
final String currencyCode = (String) row.get("currencyCode");
|
||||
|
||||
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
|
||||
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
|
||||
// savings or loan transaction id.
|
||||
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
|
||||
LocalDateTime.now());
|
||||
alertRepository.save(alert);
|
||||
|
||||
Long strCaseId = null;
|
||||
if (escalate) {
|
||||
alert.escalate(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
strCaseId = strCase.getId();
|
||||
} else {
|
||||
alert.whitelist(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
}
|
||||
|
||||
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
|
||||
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getDisposition() == null ? null : alert.getDisposition().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
|
||||
strCaseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ManualReferralData> retrieveAll() {
|
||||
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
|
||||
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
|
||||
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
|
||||
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
|
||||
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
|
||||
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
|
||||
MANUAL_RULE_CODE);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadEntry(final Long journalEntryId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
|
||||
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TRANSFER covers everything that is neither a savings nor a loan posting.
|
||||
*
|
||||
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
|
||||
* id; falling straight through to the transaction columns would mislabel those as transfers.
|
||||
*/
|
||||
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
|
||||
final Number entityType = (Number) row.get("entityType");
|
||||
if (entityType != null) {
|
||||
if (entityType.intValue() == 2) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (entityType.intValue() == 1) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
}
|
||||
if (row.get("savingsTxnId") != null) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (row.get("loanTxnId") != null) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
return AmlTransactionType.TRANSFER;
|
||||
}
|
||||
}
|
||||
-615
@@ -1,615 +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.clerkwithdrawal.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Maker-checker withdrawal (redeem shares) request workflow for a Share Account, submitted only
|
||||
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
|
||||
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
|
||||
* server-side executes the real Fineract "redeemshares" command against the share account, or
|
||||
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
|
||||
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
|
||||
* redemption is a book-entry, not a cash handover).
|
||||
*/
|
||||
@Path("/v1/clerk/share-withdrawal-requests")
|
||||
@Component
|
||||
@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||
// simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string.
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final String[] LIST_COLUMNS = { "id", "share_account_id", "account_no", "client_id", "client_name",
|
||||
"requested_shares", "requested_date", "resolution_number", "meeting_date", "description", "status", "clerk_id", "clerk_name",
|
||||
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
|
||||
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
|
||||
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
||||
|
||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
|
||||
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
|
||||
final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root);
|
||||
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
|
||||
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
|
||||
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
|
||||
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
|
||||
// it, since the CR row's account number is only ever used client-side to double-check against
|
||||
// the share account's own authoritative linked-savings-account link.
|
||||
final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null;
|
||||
final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString()
|
||||
: null;
|
||||
|
||||
if (shareAccountId == null) {
|
||||
throw apiError(400, "Share account id is required");
|
||||
}
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (requestedShares == null || requestedShares <= 0) {
|
||||
throw apiError(400, "Number of shares to withdraw must be greater than zero");
|
||||
}
|
||||
if (requestedDate == null || requestedDate.isBlank()) {
|
||||
throw apiError(400, "Requested date is required");
|
||||
}
|
||||
if (resolutionNumber == null || resolutionNumber.isBlank()) {
|
||||
throw apiError(400, "Board Resolution Number is required for a share withdrawal");
|
||||
}
|
||||
if (meetingDate == null || meetingDate.isBlank()) {
|
||||
throw apiError(400, "Date of Meeting is required for a share withdrawal");
|
||||
}
|
||||
if (settlementPaymentTypeId == null) {
|
||||
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
|
||||
}
|
||||
validateSettlementPaymentType(settlementPaymentTypeId);
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_clerk_share_withdrawal_request
|
||||
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
|
||||
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
|
||||
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
|
||||
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
|
||||
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
|
||||
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
|
||||
* of what the clerk picked in the UI.
|
||||
*/
|
||||
private void validateSettlementPaymentType(final Long paymentTypeId) {
|
||||
final Map<String, Object> paymentType = jdbcTemplate
|
||||
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst()
|
||||
.orElse(null);
|
||||
if (paymentType == null) {
|
||||
throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist");
|
||||
}
|
||||
final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment"));
|
||||
if (isCash) {
|
||||
throw apiError(400, "Settlement Payment Type '" + paymentType.get("value")
|
||||
+ "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer");
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests ── Manager sees all requests ────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||
final int offset, final int limit) {
|
||||
final StringBuilder where = new StringBuilder(baseWhere);
|
||||
final List<Object> params = new ArrayList<>(baseParams);
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional
|
||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||
params.toArray()))
|
||||
.orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ─────────────
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
return gson.toJson(loadRecord(id));
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ──
|
||||
|
||||
/**
|
||||
* Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so
|
||||
* the checker can catch things that changed since submission — a share/savings account that got
|
||||
* closed, a payment type that got deactivated, or (the exact bug class this session ran into) a
|
||||
* savings product's GL Fund Source mapping for this payment type going missing, which would
|
||||
* silently post the settlement leg to the default Cash at Vault account instead of Share Sundry.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/validate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String validate(@PathParam("id") final Long id) {
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final List<Map<String, Object>> checks = new ArrayList<>();
|
||||
|
||||
final String status = (String) record.get("status");
|
||||
checks.add(checkResult("Request status is PENDING", "PENDING".equals(status), String.valueOf(status)));
|
||||
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(),
|
||||
resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber));
|
||||
|
||||
final Object meetingDate = record.get("meeting_date");
|
||||
checks.add(checkResult("Date of Meeting provided", meetingDate != null,
|
||||
meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length()))));
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
|
||||
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
||||
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, savings_account_id AS savingsAccountId
|
||||
FROM m_share_account WHERE id = ?
|
||||
""", shareAccountId).stream().findFirst().orElse(null);
|
||||
|
||||
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
|
||||
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
|
||||
: 0L;
|
||||
checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares,
|
||||
requestedShares + " of " + heldShares + " shares currently held"));
|
||||
|
||||
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
|
||||
? ((Number) shareAccountRow.get("statusEnum")).intValue()
|
||||
: -1;
|
||||
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, "Status code: " + shareAccountStatus));
|
||||
|
||||
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
||||
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
||||
: null;
|
||||
checks.add(checkResult("Linked savings account confirmed", savingsAccountId != null,
|
||||
savingsAccountId != null ? "Savings account id " + savingsAccountId : "No linked savings account configured"));
|
||||
|
||||
Integer savingsStatus = null;
|
||||
Long savingsProductId = null;
|
||||
if (savingsAccountId != null) {
|
||||
final Map<String, Object> savingsRow = jdbcTemplate
|
||||
.queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?",
|
||||
savingsAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue()
|
||||
: null;
|
||||
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
|
||||
: null;
|
||||
}
|
||||
checks.add(checkResult("Linked savings account is Active", savingsStatus != null && savingsStatus == 300,
|
||||
savingsStatus != null ? "Status code: " + savingsStatus : "Not found"));
|
||||
|
||||
final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number
|
||||
? ((Number) settlementPaymentTypeIdObj).longValue()
|
||||
: null;
|
||||
final Object settlementPaymentTypeName = record.get("settlement_payment_type_name");
|
||||
checks.add(checkResult("Settlement Payment Type selected", settlementPaymentTypeId != null,
|
||||
settlementPaymentTypeName != null ? settlementPaymentTypeName.toString() : "Missing"));
|
||||
|
||||
boolean paymentTypeNonCash = false;
|
||||
if (settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> pt = jdbcTemplate
|
||||
.queryForList("SELECT is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream()
|
||||
.findFirst().orElse(null);
|
||||
paymentTypeNonCash = pt != null && !Boolean.TRUE.equals(pt.get("is_cash_payment"));
|
||||
}
|
||||
checks.add(checkResult("Settlement Payment Type is non-cash", paymentTypeNonCash,
|
||||
paymentTypeNonCash ? "Confirmed non-cash" : "Cash payment type or not found — settlement would be rejected"));
|
||||
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
if (savingsProductId != null && settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
|
||||
SELECT gl.name AS glName FROM acc_product_mapping apm
|
||||
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
|
||||
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
|
||||
glMappingOk = mapping != null;
|
||||
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
|
||||
: "No GL Fund Source override configured for this Payment Type — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
|
||||
}
|
||||
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("label", label);
|
||||
result.put("pass", pass);
|
||||
result.put("detail", detail);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ──
|
||||
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId())) {
|
||||
throw apiError(403,
|
||||
"Maker-checker: you cannot approve a share withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
final Object requestedDateObj = record.get("requested_date");
|
||||
final Object meetingDateObj = record.get("meeting_date");
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString()));
|
||||
payload.addProperty("requestedShares", requestedShares);
|
||||
payload.addProperty("resolutionNumber", resolutionNumber);
|
||||
payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString()));
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
|
||||
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
|
||||
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
|
||||
final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null;
|
||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||
|
||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||
// to the member's linked savings account. Two Fineract journal entries get posted — native
|
||||
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
|
||||
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
|
||||
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
|
||||
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
||||
? ((Number) storedSettlementPaymentTypeId).longValue()
|
||||
: null;
|
||||
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
|
||||
resolutionNumber, settlementPaymentTypeId);
|
||||
|
||||
final String settlementNote = settlement != null
|
||||
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
|
||||
: "No linked savings account on this share account — settlement must be completed manually.";
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
|
||||
settlementNote);
|
||||
|
||||
final int approvedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
share_transaction_id = ?, activity_log = ?, actioned_at = ?,
|
||||
settlement_amount = ?, settlement_savings_transaction_id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
|
||||
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
|
||||
settlement != null ? settlement.savingsTransactionId() : null, id);
|
||||
if (approvedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (shareTxnId != null) {
|
||||
res.addProperty("shareTransactionId", shareTxnId);
|
||||
}
|
||||
if (settlement != null) {
|
||||
res.addProperty("settlementAmount", settlement.amount());
|
||||
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
|
||||
} else {
|
||||
res.addProperty("settlementWarning", settlementNote);
|
||||
}
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||
|
||||
/**
|
||||
* Credits the redemption value to the share account's linked savings account, if one exists.
|
||||
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
||||
* the unit price ourselves — the transaction was already priced by Fineract's own
|
||||
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
||||
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
|
||||
* command so the transaction is fully audited and posts its own GL entry the normal way — the
|
||||
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
|
||||
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
|
||||
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
|
||||
* on that bridge account. Falls back to the legacy hardcoded
|
||||
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
|
||||
* column existed.
|
||||
*/
|
||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForList("""
|
||||
SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount
|
||||
FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id
|
||||
WHERE sat.id = ?
|
||||
""", shareTransactionId).stream().findFirst().orElse(null);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue();
|
||||
if (savingsAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
|
||||
|
||||
final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
|
||||
: jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
|
||||
SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
|
||||
payload.addProperty("transactionAmount", settlementAmount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", paymentTypeId);
|
||||
}
|
||||
payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return new Settlement(settlementAmount, result.getResourceId());
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Rejection reason is required");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
|
||||
|
||||
final int rejectedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
|
||||
Timestamp.from(Instant.now()), id);
|
||||
if (rejectedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject share withdrawal requests.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Share withdrawal request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String formatForFineract(final String isoOrTimestamp) {
|
||||
try {
|
||||
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
|
||||
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
if (existingJson != null && !existingJson.isBlank()) {
|
||||
try {
|
||||
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
|
||||
} catch (Exception e) {
|
||||
log = new JsonArray();
|
||||
}
|
||||
}
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("timestamp", Instant.now().toString());
|
||||
entry.addProperty("action", action);
|
||||
entry.addProperty("user", username);
|
||||
if (note != null && !note.isBlank()) {
|
||||
entry.addProperty("note", note);
|
||||
}
|
||||
log.add(entry);
|
||||
return gson.toJson(log);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("message", message);
|
||||
body.addProperty("defaultUserMessage", message);
|
||||
body.addProperty("httpStatus", httpStatus);
|
||||
final JsonArray errors = new JsonArray();
|
||||
final JsonObject err = new JsonObject();
|
||||
err.addProperty("defaultUserMessage", message);
|
||||
errors.add(err);
|
||||
body.add("errors", errors);
|
||||
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+84
-3
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/clients")
|
||||
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
|
||||
|
||||
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
|
||||
* whether the token matched, to avoid leaking token validity via response differences. */
|
||||
private static final byte[] TRANSPARENT_GIF = {
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
|
||||
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
|
||||
};
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a pending Client details edit",
|
||||
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
||||
+ "The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@Context final HttpServletRequest httpRequest) {
|
||||
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||
}
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
||||
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
||||
// reachable by the client's email client without needing a configured server.url property.
|
||||
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
|
||||
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
|
||||
: ":" + httpRequest.getServerPort());
|
||||
|
||||
// Tenant ID from the Fineract-specific header (same header the frontend sends).
|
||||
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
|
||||
tenantId != null ? tenantId : "default");
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
// ── Email preview (authenticated) ─────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
||||
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
||||
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
||||
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
||||
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
||||
if (preview == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("edit/pixel/{token}")
|
||||
@Produces("image/gif")
|
||||
@Operation(summary = "Email open tracking pixel",
|
||||
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
||||
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
||||
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
||||
public Response trackEmailOpen(@PathParam("token") final String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
try {
|
||||
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
||||
if (pendingEditOpt.isPresent()) {
|
||||
final var pendingEdit = pendingEditOpt.get();
|
||||
pendingEdit.markEmailOpened();
|
||||
pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
// Never fail a pixel request — email clients don't handle errors well.
|
||||
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
||||
}
|
||||
}
|
||||
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.header("Pragma", "no-cache")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
-111
@@ -1,111 +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.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Customer-outreach log for KYC exceptions.
|
||||
*
|
||||
* <pre>
|
||||
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
|
||||
* GET /v1/kyc/exceptions/{clientId}/contact-log
|
||||
* </pre>
|
||||
*
|
||||
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
|
||||
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
|
||||
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
|
||||
* body with HTTP 200, not as an error, because the log row was still written.
|
||||
*
|
||||
* <p>No PUT or DELETE: the log is append-only evidence.
|
||||
*
|
||||
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
|
||||
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
|
||||
* already holds it.
|
||||
*/
|
||||
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycContactLogApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycContactLogService kycContactLogService;
|
||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Log a customer contact attempt for a KYC exception",
|
||||
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
|
||||
+ "customer using the destination held on their client record. Returns the persisted log including "
|
||||
+ "its delivery outcome.")
|
||||
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
|
||||
description = "All logged outreach for this customer, oldest first.")
|
||||
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
|
||||
return this.toApiJsonSerializer.serialize(logs);
|
||||
}
|
||||
|
||||
private JsonObject parse(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(json);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject payload, final String member) {
|
||||
final JsonElement element = payload.get(member);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
}
|
||||
-73
@@ -1,73 +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.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
|
||||
@Path("/v1/kyc/exceptions")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycExceptionsApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientKycReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
|
||||
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
|
||||
this.context.authenticatedUser();
|
||||
final List<ClientKycData> exceptions;
|
||||
if (Boolean.TRUE.equals(resolved)) {
|
||||
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
|
||||
} else {
|
||||
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
|
||||
}
|
||||
for (ClientKycData k : exceptions) {
|
||||
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
|
||||
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
|
||||
}
|
||||
return this.toApiJsonSerializer.serialize(exceptions);
|
||||
}
|
||||
}
|
||||
+3
-23
@@ -41,9 +41,6 @@ public class ClientKycCommand {
|
||||
private final Boolean verifyPan;
|
||||
private final Boolean verifyAadhaar;
|
||||
private final Boolean verifyGstin;
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
|
||||
// ── Aadhaar OTP flow ──────────────────────────────────────────────────────
|
||||
/** Full Aadhaar – used ONLY for OTP generation; never stored. */
|
||||
@@ -57,8 +54,7 @@ public class ClientKycCommand {
|
||||
final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
|
||||
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
|
||||
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
|
||||
final String aadhaarOtp, final Boolean isException, final String exceptionReason,
|
||||
final Boolean exceptionResolved) {
|
||||
final String aadhaarOtp) {
|
||||
this.kycType = kycType;
|
||||
this.riskCategory = riskCategory;
|
||||
this.panNumber = panNumber;
|
||||
@@ -72,9 +68,6 @@ public class ClientKycCommand {
|
||||
this.aadhaarNumber = aadhaarNumber;
|
||||
this.aadhaarRefNumber = aadhaarRefNumber;
|
||||
this.aadhaarOtp = aadhaarOtp;
|
||||
this.isException = isException;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public void validateForInitiate() {
|
||||
@@ -118,9 +111,8 @@ public class ClientKycCommand {
|
||||
}
|
||||
|
||||
// At least one ID document must be provided — except for minors, who prove identity via
|
||||
// the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
|
||||
final boolean isKycException = isException != null && isException;
|
||||
if (!skipIdentityDocumentCheck && !isKycException) {
|
||||
// the Birth Certificate / School ID document-type picker instead.
|
||||
if (!skipIdentityDocumentCheck) {
|
||||
final boolean hasPan = panNumber != null && !panNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
|
||||
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
|
||||
@@ -258,16 +250,4 @@ public class ClientKycCommand {
|
||||
public String getAadhaarRefNumber() { return aadhaarRefNumber; }
|
||||
/** OTP entered by the client. */
|
||||
public String getAadhaarOtp() { return aadhaarOtp; }
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||
nameVerifiedParamName, screeningLogIdParamName,
|
||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
||||
"oldMobileVerificationExceptionDocumentId"));
|
||||
|
||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||
|
||||
+5
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
|
||||
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
|
||||
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
||||
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
||||
|
||||
+3
-14
@@ -39,9 +39,6 @@ public final class ClientKycApiConstants {
|
||||
public static final String verifyPanParam = "verifyPan";
|
||||
public static final String verifyAadhaarParam = "verifyAadhaar";
|
||||
public static final String verifyGstinParam = "verifyGstin";
|
||||
public static final String panVerifiedParam = "panVerified";
|
||||
public static final String aadhaarVerifiedParam = "aadhaarVerified";
|
||||
public static final String gstinVerifiedParam = "gstinVerified";
|
||||
|
||||
// ── Aadhaar OTP flow parameters ───────────────────────────────────────────
|
||||
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
|
||||
@@ -77,10 +74,6 @@ public final class ClientKycApiConstants {
|
||||
public static final String form15ApplicableParam = "form15Applicable";
|
||||
public static final String form15TypeParam = "form15Type";
|
||||
public static final String form15FYParam = "form15FY";
|
||||
public static final String isExceptionParam = "isException";
|
||||
public static final String exceptionReasonParam = "exceptionReason";
|
||||
public static final String exceptionResolvedParam = "exceptionResolved";
|
||||
public static final String exceptionResolvedOnParam = "exceptionResolvedOn";
|
||||
|
||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
@@ -90,20 +83,16 @@ public final class ClientKycApiConstants {
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
|
||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
|
||||
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
-19
@@ -97,29 +97,10 @@ public final class ClientKycData {
|
||||
private final LocalDate ckycUploadedOn;
|
||||
private final String ckycBatchId;
|
||||
|
||||
// ── Exception management ──────────────────────────────────────────────────
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
private final LocalDate exceptionResolvedOn;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
private final LocalDate initiatedOn;
|
||||
private final LocalDate verifiedOn;
|
||||
private final LocalDate rejectedOn;
|
||||
private final LocalDate expiryDate;
|
||||
private final String remarks;
|
||||
|
||||
private final String clientAccountNo;
|
||||
private final String clientOfficeName;
|
||||
|
||||
// ── Customer contact (for KYC outreach) ──────────────────────────────────
|
||||
// Masked only. The exceptions screen shows these so the officer knows who they are reaching,
|
||||
// but the full values never leave the server — the send path resolves them from m_client
|
||||
// itself, so a tampered browser cannot redirect a KYC notice to another address.
|
||||
private final String clientMobileMasked;
|
||||
private final String clientEmailMasked;
|
||||
/** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */
|
||||
private final Boolean hasMobile;
|
||||
private final Boolean hasEmail;
|
||||
}
|
||||
|
||||
+14
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
|
||||
private final String checkerName;
|
||||
private final OffsetDateTime checkedDate;
|
||||
private final String rejectReason;
|
||||
|
||||
// ── Email tracking fields ─────────────────────────────────────────────────
|
||||
|
||||
/** Whether a confirmation email was successfully sent to the client after approval. */
|
||||
private final Boolean emailSent;
|
||||
|
||||
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
|
||||
private final OffsetDateTime emailSentAt;
|
||||
|
||||
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
|
||||
private final OffsetDateTime emailOpenedAt;
|
||||
|
||||
// emailSubject and emailBody are deliberately NOT included in the list DTO —
|
||||
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
|
||||
}
|
||||
|
||||
-64
@@ -1,64 +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.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One KYC customer-contact attempt as returned to the UI.
|
||||
*
|
||||
* <p>{@code deliveryStatus} and {@code deliveryMessage} exist so the officer can tell a delivered
|
||||
* notice from a failed one at a glance — the dialog shows the failure verbatim rather than a
|
||||
* generic success toast.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public final class KycContactLogData {
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final Long kycId;
|
||||
|
||||
/** Enum name, e.g. {@code SMS}. */
|
||||
private final String channel;
|
||||
/** Human label, e.g. {@code SMS}/{@code In-Branch Visit} — what the grid renders. */
|
||||
private final String channelLabel;
|
||||
|
||||
private final String notes;
|
||||
private final String officerName;
|
||||
private final String exceptionReason;
|
||||
|
||||
/** NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED */
|
||||
private final String deliveryStatus;
|
||||
/** True only for a channel that was supposed to transmit and did. */
|
||||
private final Boolean delivered;
|
||||
/** True when this channel transmits at all (SMS/Email/WhatsApp) vs. is recorded offline. */
|
||||
private final Boolean platformSent;
|
||||
/** Officer-facing sentence describing the outcome — success or the reason it failed. */
|
||||
private final String deliveryMessage;
|
||||
|
||||
private final String destinationMasked;
|
||||
private final String provider;
|
||||
private final String providerReference;
|
||||
private final String messageBody;
|
||||
|
||||
private final LocalDateTime contactedAt;
|
||||
}
|
||||
+53
-1
@@ -46,6 +46,20 @@ public final class KycRiskRuleData implements Serializable {
|
||||
private final String willfulDefaulter;
|
||||
private final String ecgcSalFlag;
|
||||
private final String dormantFlag;
|
||||
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
|
||||
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
|
||||
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
|
||||
private final BigDecimal txnThresholdAmount;
|
||||
private final String txnRuleType;
|
||||
private final Integer txnWindowDays;
|
||||
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
|
||||
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
|
||||
private final BigDecimal txnThresholdAmountMediumRisk;
|
||||
private final BigDecimal txnThresholdAmountHighRisk;
|
||||
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
|
||||
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||
private final BigDecimal txnMinLimitAmount;
|
||||
private final BigDecimal txnMaxLimitAmount;
|
||||
|
||||
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||
@@ -55,7 +69,10 @@ public final class KycRiskRuleData implements Serializable {
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
||||
final Integer riskCategory, final String reason, final Integer orderPosition,
|
||||
final String amlCheckStatus, final String bannedEntityMatch,
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) {
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
||||
this.id = id;
|
||||
this.ruleName = ruleName;
|
||||
this.legalFormId = legalFormId;
|
||||
@@ -77,6 +94,13 @@ public final class KycRiskRuleData implements Serializable {
|
||||
this.willfulDefaulter = willfulDefaulter;
|
||||
this.ecgcSalFlag = ecgcSalFlag;
|
||||
this.dormantFlag = dormantFlag;
|
||||
this.txnThresholdAmount = txnThresholdAmount;
|
||||
this.txnRuleType = txnRuleType;
|
||||
this.txnWindowDays = txnWindowDays;
|
||||
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
|
||||
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -162,4 +186,32 @@ public final class KycRiskRuleData implements Serializable {
|
||||
public String getDormantFlag() {
|
||||
return dormantFlag;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmount() {
|
||||
return txnThresholdAmount;
|
||||
}
|
||||
|
||||
public String getTxnRuleType() {
|
||||
return txnRuleType;
|
||||
}
|
||||
|
||||
public Integer getTxnWindowDays() {
|
||||
return txnWindowDays;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmountMediumRisk() {
|
||||
return txnThresholdAmountMediumRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnThresholdAmountHighRisk() {
|
||||
return txnThresholdAmountHighRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnMinLimitAmount() {
|
||||
return txnMinLimitAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getTxnMaxLimitAmount() {
|
||||
return txnMaxLimitAmount;
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_family_member_children")
|
||||
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "family_member_id", nullable = false)
|
||||
private ClientFamilyMembers familyMember;
|
||||
|
||||
@Column(name = "full_name", nullable = false)
|
||||
private String fullName;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gender_cv_id", nullable = false)
|
||||
private CodeValue gender;
|
||||
|
||||
@Column(name = "date_of_birth", nullable = true)
|
||||
private LocalDate dateOfBirth;
|
||||
|
||||
protected ClientFamilyMemberChildren() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
this.familyMember = familyMember;
|
||||
this.fullName = fullName;
|
||||
this.gender = gender;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
|
||||
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
|
||||
}
|
||||
|
||||
public ClientFamilyMembers getFamilyMember() {
|
||||
return this.familyMember;
|
||||
}
|
||||
|
||||
public void setFamilyMember(ClientFamilyMembers familyMember) {
|
||||
this.familyMember = familyMember;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return this.fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public CodeValue getGender() {
|
||||
return this.gender;
|
||||
}
|
||||
|
||||
public void setGender(CodeValue gender) {
|
||||
this.gender = gender;
|
||||
}
|
||||
|
||||
public LocalDate getDateOfBirth() {
|
||||
return this.dateOfBirth;
|
||||
}
|
||||
|
||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
}
|
||||
+18
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "modified_on")
|
||||
private LocalDateTime modifiedOn;
|
||||
|
||||
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
|
||||
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
|
||||
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
||||
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
|
||||
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
||||
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public Long getModifiedBy() { return modifiedBy; }
|
||||
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
||||
|
||||
public java.util.List<ClientFamilyMemberChildren> getChildren() {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
|
||||
if (this.children == null) {
|
||||
this.children = new java.util.ArrayList<>();
|
||||
} else {
|
||||
this.children.clear();
|
||||
}
|
||||
if (children != null) {
|
||||
this.children.addAll(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||
private String pendingLockKey;
|
||||
|
||||
// ── Email tracking ────────────────────────────────────────────────────────
|
||||
|
||||
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
|
||||
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
|
||||
@Column(name = "email_tracking_token", length = 36, unique = true)
|
||||
private String emailTrackingToken;
|
||||
|
||||
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
|
||||
@Column(name = "email_sent", nullable = false)
|
||||
private Boolean emailSent = Boolean.FALSE;
|
||||
|
||||
/** Timestamp of the successful send. Null when emailSent is false. */
|
||||
@Column(name = "email_sent_at")
|
||||
private OffsetDateTime emailSentAt;
|
||||
|
||||
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
|
||||
* the 1×1 img tag. Null until opened. */
|
||||
@Column(name = "email_opened_at")
|
||||
private OffsetDateTime emailOpenedAt;
|
||||
|
||||
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
|
||||
* without regenerating the template. */
|
||||
@Column(name = "email_subject", length = 300)
|
||||
private String emailSubject;
|
||||
|
||||
/** Exact HTML body that was sent — stored alongside email_subject. */
|
||||
@Column(name = "email_body", columnDefinition = "TEXT")
|
||||
private String emailBody;
|
||||
|
||||
/** Exact sender address configured at the time the email was dispatched. */
|
||||
@Column(name = "email_from", length = 255)
|
||||
private String emailFrom;
|
||||
|
||||
// ── Factory / lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||
final AppUser maker) {
|
||||
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
edit.makerId = maker.getId();
|
||||
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||
edit.emailSent = Boolean.FALSE;
|
||||
return edit;
|
||||
}
|
||||
|
||||
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
this.rejectReason = reason;
|
||||
this.pendingLockKey = null;
|
||||
}
|
||||
|
||||
/** Called after a successful email send. Records the token (for pixel tracking), the exact
|
||||
* subject and body, and marks the row as sent. */
|
||||
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
|
||||
this.emailTrackingToken = token;
|
||||
this.emailSubject = subject;
|
||||
this.emailBody = body;
|
||||
this.emailFrom = fromAddress;
|
||||
this.emailSent = Boolean.TRUE;
|
||||
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
|
||||
public void markEmailOpened() {
|
||||
if (this.emailOpenedAt == null) {
|
||||
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
|
||||
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||
|
||||
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||
|
||||
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
|
||||
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
|
||||
}
|
||||
|
||||
+6
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
|
||||
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||
}
|
||||
|
||||
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
|
||||
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
|
||||
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
|
||||
return this.repository.findByEmailTrackingToken(token);
|
||||
}
|
||||
|
||||
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||
return this.repository.saveAndFlush(edit);
|
||||
}
|
||||
|
||||
-74
@@ -1,74 +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.notification;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Routes an outbound customer notice to the {@link CustomerNotificationSender} registered for its
|
||||
* channel.
|
||||
*
|
||||
* <p>Senders are injected as a list and indexed by channel at startup, so a new gateway becomes
|
||||
* available by declaring a bean — nothing here changes.
|
||||
*/
|
||||
@Component
|
||||
public class CustomerNotificationDispatcher {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CustomerNotificationDispatcher.class);
|
||||
|
||||
private final Map<KycContactChannel, CustomerNotificationSender> sendersByChannel = new EnumMap<>(KycContactChannel.class);
|
||||
|
||||
public CustomerNotificationDispatcher(final List<CustomerNotificationSender> senders) {
|
||||
for (final CustomerNotificationSender sender : senders) {
|
||||
final CustomerNotificationSender existing = this.sendersByChannel.put(sender.getChannel(), sender);
|
||||
if (existing != null) {
|
||||
LOG.warn("Two senders registered for channel {} — {} replaced {}", sender.getChannel(),
|
||||
sender.getClass().getSimpleName(), existing.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
LOG.info("Customer notification channels available: {}", this.sendersByChannel.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send on the given channel.
|
||||
*
|
||||
* @return the outcome; {@link CustomerNotificationResult#notConfigured} for an offline channel
|
||||
* or one with no registered sender. Never throws for a delivery failure.
|
||||
*/
|
||||
public CustomerNotificationResult dispatch(final KycContactChannel channel, final Client client, final String subject,
|
||||
final String plainBody, final String htmlBody) {
|
||||
if (!channel.isPlatformSent()) {
|
||||
// Caller shouldn't reach here for PHONE_CALL / visits, but guard rather than send.
|
||||
return CustomerNotificationResult.notConfigured(null, "Channel " + channel + " is recorded offline, not transmitted");
|
||||
}
|
||||
final CustomerNotificationSender sender = this.sendersByChannel.get(channel);
|
||||
if (sender == null) {
|
||||
LOG.warn("No sender registered for channel {} — notice to client {} not transmitted.", channel, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(null, "No gateway is registered for " + channel);
|
||||
}
|
||||
return sender.send(client, subject, plainBody, htmlBody);
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +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.notification;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
|
||||
/**
|
||||
* What a {@link CustomerNotificationSender} did. Senders never throw for a delivery failure — they
|
||||
* return a {@code FAILED} result — so that a gateway outage still produces a persisted contact-log
|
||||
* row rather than losing the officer's notes to a rolled-back transaction.
|
||||
*/
|
||||
public record CustomerNotificationResult(KycContactDeliveryStatus status, String destinationMasked, String provider,
|
||||
String providerReference, String failureReason) {
|
||||
|
||||
public static CustomerNotificationResult sent(final String destinationMasked, final String provider, final String reference) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.SENT, destinationMasked, provider, reference, null);
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult failed(final String destinationMasked, final String provider, final String reason) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.FAILED, destinationMasked, provider, null, reason);
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult noDestination(final String provider) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.SKIPPED_NO_DESTINATION, null, provider, null,
|
||||
"Customer has no destination on file for this channel");
|
||||
}
|
||||
|
||||
public static CustomerNotificationResult notConfigured(final String provider, final String reason) {
|
||||
return new CustomerNotificationResult(KycContactDeliveryStatus.PROVIDER_NOT_CONFIGURED, null, provider, null, reason);
|
||||
}
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this.status.isDelivered();
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +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.notification;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
|
||||
/**
|
||||
* Transport for one outbound customer channel.
|
||||
*
|
||||
* <p>Implementations are discovered by Spring and matched to a channel via {@link #getChannel()},
|
||||
* so adding a gateway means adding a bean — no dispatcher edit. Each implementation is responsible
|
||||
* for resolving the destination off the {@link Client} itself; callers never pass a destination in,
|
||||
* which keeps a browser from being able to redirect a KYC notice to an arbitrary address.
|
||||
*
|
||||
* <p>Contract: never throw for a delivery problem. Return
|
||||
* {@link CustomerNotificationResult#failed} instead, so the officer's contact log still persists.
|
||||
*/
|
||||
public interface CustomerNotificationSender {
|
||||
|
||||
KycContactChannel getChannel();
|
||||
|
||||
/**
|
||||
* @param client the customer to reach; the destination is read off this record
|
||||
* @param subject short title — used by channels that have one (email), ignored by those that don't
|
||||
* @param plainBody the message as plain text; SMS/WhatsApp send this verbatim
|
||||
* @param htmlBody rich rendering for channels that support it; may be null
|
||||
*/
|
||||
CustomerNotificationResult send(Client client, String subject, String plainBody, String htmlBody);
|
||||
}
|
||||
-81
@@ -1,81 +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.notification;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Email delivery via {@link EmailSenderService} — the direct-SMTP path configured from this app's
|
||||
* own {@code spring.mail.*} properties, the same one already used for OTP emails and client-change
|
||||
* alerts.
|
||||
*
|
||||
* <p>Deliberately not {@code PlatformEmailService}: that resolves SMTP credentials from the
|
||||
* DB-backed "external services" table used by the campaign pipeline, which is a separate and
|
||||
* typically unconfigured surface in this deployment.
|
||||
*/
|
||||
@Component
|
||||
public class EmailCustomerNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EmailCustomerNotificationSender.class);
|
||||
private static final String PROVIDER = "SMTP";
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
|
||||
public EmailCustomerNotificationSender(final EmailSenderService emailSenderService) {
|
||||
this.emailSenderService = emailSenderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String to = client.getEmailAddress();
|
||||
if (StringUtils.isBlank(to)) {
|
||||
LOG.warn("KYC contact email skipped — client {} has no email address on file.", client.getId());
|
||||
return CustomerNotificationResult.noDestination(PROVIDER);
|
||||
}
|
||||
|
||||
final String masked = OtpDestinationMasker.maskEmail(to);
|
||||
try {
|
||||
final boolean accepted = StringUtils.isNotBlank(htmlBody)
|
||||
? this.emailSenderService.sendHtml(to, subject, htmlBody)
|
||||
: this.emailSenderService.sendText(to, subject, plainBody);
|
||||
|
||||
if (accepted) {
|
||||
LOG.info("KYC contact email sent to client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.sent(masked, PROVIDER, null);
|
||||
}
|
||||
LOG.warn("KYC contact email rejected by SMTP for client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, "SMTP rejected the message");
|
||||
} catch (final Exception e) {
|
||||
LOG.error("KYC contact email failed for client {} at {}", client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
-213
@@ -1,213 +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.notification;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Shared HTTP plumbing for the SMS and WhatsApp gateways.
|
||||
*
|
||||
* <p><b>Why this is template-driven rather than one class per vendor.</b> Indian SMS/WhatsApp
|
||||
* gateways (MSG91, Gupshup, Kaleyra, Twilio, LincPay, …) all expose the same shape — POST a
|
||||
* destination plus a body to a URL with a static API key — but disagree on field names, auth header,
|
||||
* and whether the payload is JSON or form-encoded. Encoding those three differences as properties
|
||||
* means switching or adding a vendor is a config change, not a code change, and no vendor's
|
||||
* credentials end up hard-coded in the repo.
|
||||
*
|
||||
* <p>The body template supports these placeholders, substituted with correct escaping for the
|
||||
* configured content type:
|
||||
* <ul>
|
||||
* <li>{@code {{destination}}} — the customer's mobile number</li>
|
||||
* <li>{@code {{message}}} — the notice text</li>
|
||||
* <li>{@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number</li>
|
||||
* <li>{@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>TRAI's DLT regime means a transactional SMS to an Indian number is only accepted when the body
|
||||
* matches a pre-registered template, so {@code templateId} is exposed even though not every vendor
|
||||
* needs it.
|
||||
*/
|
||||
abstract class RestGatewayNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
protected abstract Logger log();
|
||||
|
||||
/** Human-readable gateway name recorded on the contact log (e.g. {@code MSG91}). */
|
||||
protected abstract String providerName();
|
||||
|
||||
protected abstract boolean isEnabled();
|
||||
|
||||
protected abstract String baseUrl();
|
||||
|
||||
protected abstract String path();
|
||||
|
||||
protected abstract String authHeaderName();
|
||||
|
||||
protected abstract String authHeaderValue();
|
||||
|
||||
protected abstract String contentType();
|
||||
|
||||
protected abstract String bodyTemplate();
|
||||
|
||||
protected abstract String senderId();
|
||||
|
||||
protected abstract String templateId();
|
||||
|
||||
/**
|
||||
* Dotted path into the JSON response holding the vendor's message ID (e.g.
|
||||
* {@code data.messageId}). Blank when the vendor returns no usable reference.
|
||||
*/
|
||||
protected abstract String referenceJsonPath();
|
||||
|
||||
/** Resolve the customer's destination for this channel off the client record. */
|
||||
protected abstract String resolveDestination(Client client);
|
||||
|
||||
/** Mask the destination for storage and logging. */
|
||||
protected abstract String maskDestination(String destination);
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String provider = providerName();
|
||||
|
||||
if (!isEnabled()) {
|
||||
log().warn("{} notice not sent for client {} — channel disabled in configuration.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider,
|
||||
"Channel is disabled — set the corresponding fineract.notification.*.enabled property to true");
|
||||
}
|
||||
if (StringUtils.isBlank(baseUrl()) || StringUtils.isBlank(authHeaderValue())) {
|
||||
log().warn("{} notice not sent for client {} — gateway URL or credential missing.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider, "Gateway base-url or API credential is not configured");
|
||||
}
|
||||
|
||||
final String destination = resolveDestination(client);
|
||||
if (StringUtils.isBlank(destination)) {
|
||||
log().warn("{} notice skipped — client {} has no destination on file for this channel.", provider, client.getId());
|
||||
return CustomerNotificationResult.noDestination(provider);
|
||||
}
|
||||
|
||||
final String masked = maskDestination(destination);
|
||||
final boolean json = !MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType());
|
||||
final String payload = renderBody(destination, plainBody, json);
|
||||
|
||||
try {
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(contentType()));
|
||||
if (StringUtils.isNotBlank(authHeaderName())) {
|
||||
headers.set(authHeaderName(), authHeaderValue());
|
||||
}
|
||||
|
||||
final String base = baseUrl().endsWith("/") ? baseUrl().substring(0, baseUrl().length() - 1) : baseUrl();
|
||||
final String url = base + path();
|
||||
final ResponseEntity<String> response = this.restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
final String reference = extractReference(response.getBody());
|
||||
log().info("{} notice sent to client {} at {} (ref={})", provider, client.getId(), masked, reference);
|
||||
return CustomerNotificationResult.sent(masked, provider, reference);
|
||||
}
|
||||
// Non-2xx without an exception is unusual for RestTemplate but treat it as a failure
|
||||
// rather than assuming delivery — an over-optimistic SENT would corrupt the audit trail.
|
||||
log().warn("{} notice rejected for client {} at {} — HTTP {}", provider, client.getId(), masked, response.getStatusCode());
|
||||
return CustomerNotificationResult.failed(masked, provider, "Gateway returned HTTP " + response.getStatusCode());
|
||||
} catch (final Exception e) {
|
||||
log().error("{} notice failed for client {} at {}", provider, client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, provider, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the placeholders, escaping each value for the target encoding. Escaping happens per
|
||||
* value rather than on the finished string so that a message containing a quote or an ampersand
|
||||
* cannot break out of its field and corrupt the request.
|
||||
*/
|
||||
private String renderBody(final String destination, final String message, final boolean json) {
|
||||
String body = bodyTemplate();
|
||||
body = body.replace("{{destination}}", escape(destination, json));
|
||||
body = body.replace("{{message}}", escape(message, json));
|
||||
body = body.replace("{{senderId}}", escape(StringUtils.defaultString(senderId()), json));
|
||||
body = body.replace("{{templateId}}", escape(StringUtils.defaultString(templateId()), json));
|
||||
return body;
|
||||
}
|
||||
|
||||
private static String escape(final String value, final boolean json) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (!json) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
final StringBuilder out = new StringBuilder(value.length() + 16);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
final char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> out.append("\\\"");
|
||||
case '\\' -> out.append("\\\\");
|
||||
case '\n' -> out.append("\\n");
|
||||
case '\r' -> out.append("\\r");
|
||||
case '\t' -> out.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) {
|
||||
out.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
out.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** Walk {@link #referenceJsonPath()} through the response body; null if absent or unparseable. */
|
||||
private String extractReference(final String responseBody) {
|
||||
final String jsonPath = referenceJsonPath();
|
||||
if (StringUtils.isBlank(jsonPath) || StringUtils.isBlank(responseBody)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonElement node = JsonParser.parseString(responseBody);
|
||||
for (final String segment : jsonPath.split("\\.")) {
|
||||
if (node == null || !node.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject object = node.getAsJsonObject();
|
||||
if (!object.has(segment)) {
|
||||
return null;
|
||||
}
|
||||
node = object.get(segment);
|
||||
}
|
||||
return node != null && node.isJsonPrimitive() ? node.getAsString() : null;
|
||||
} catch (final Exception e) {
|
||||
log().debug("Could not read reference '{}' from {} response", jsonPath, providerName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
-151
@@ -1,151 +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.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Plain-text SMS via the configured gateway. See {@link RestGatewayNotificationSender} for why the
|
||||
* request shape is property-driven.
|
||||
*
|
||||
* <p>Note this is a <em>separate</em> gateway from the LincPay eKYC endpoints already configured
|
||||
* for login OTP ({@code /mobile/generate-otp}). That endpoint generates and delivers an OTP of
|
||||
* LincPay's own choosing; it cannot carry an arbitrary message, and routing a KYC notice through it
|
||||
* would mint a real, verifiable OTP the customer never requested. Configure a genuine
|
||||
* send-message endpoint here.
|
||||
*/
|
||||
@Component
|
||||
public class SmsCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SmsCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.sms.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.sms.provider:SMS_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.sms.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.sms.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.sms.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.sms.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.sms.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.sms.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.sms.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.SMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return client.getMobileNo();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +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.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* WhatsApp Business delivery via the configured gateway (Meta Cloud API, Gupshup, Twilio, …).
|
||||
*
|
||||
* <p>Reuses the customer's registered mobile number as the WhatsApp destination — banks do not
|
||||
* capture a separate WhatsApp handle, and the Business API is keyed on the phone number anyway.
|
||||
* Most providers want it in E.164 form, so {@code fineract.notification.whatsapp.country-code} is
|
||||
* prefixed to bare 10-digit Indian numbers.
|
||||
*
|
||||
* <p>Meta's policy only permits business-initiated messages against an approved template outside the
|
||||
* 24-hour customer service window, which a KYC exception notice always is — so
|
||||
* {@code template-id} will normally need to be set alongside a template-shaped body.
|
||||
*/
|
||||
@Component
|
||||
public class WhatsAppCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WhatsAppCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.whatsapp.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.provider:WHATSAPP_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
/** Prefixed to bare national numbers to reach E.164. Blank disables normalisation. */
|
||||
@Value("${fineract.notification.whatsapp.country-code:91}")
|
||||
private String countryCode;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.WHATSAPP;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return toE164(client.getMobileNo());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip formatting and prefix the country code when the number is stored nationally. A number
|
||||
* that already carries a country code (leading {@code +}, or longer than 10 digits) is passed
|
||||
* through so we never double-prefix.
|
||||
*/
|
||||
private String toE164(final String mobileNo) {
|
||||
if (mobileNo == null || mobileNo.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String digits = mobileNo.replaceAll("[^0-9+]", "");
|
||||
if (digits.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (digits.startsWith("+")) {
|
||||
return digits.substring(1);
|
||||
}
|
||||
if (this.countryCode == null || this.countryCode.isBlank() || digits.length() > 10) {
|
||||
return digits;
|
||||
}
|
||||
return this.countryCode + digits;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
|
||||
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
||||
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
||||
|
||||
public static final String CHILDREN = "children";
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
||||
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
||||
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
|
||||
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
||||
public static final String RELATION_SHIP_ID = "relationShipId";
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
+1
-9
@@ -76,16 +76,8 @@ public class ClientKycCommandFromApiJsonDeserializer {
|
||||
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
|
||||
ClientKycApiConstants.aadhaarOtpParam, element);
|
||||
|
||||
final Boolean isException = this.fromApiJsonHelper.extractBooleanNamed(
|
||||
ClientKycApiConstants.isExceptionParam, element);
|
||||
final String exceptionReason = this.fromApiJsonHelper.extractStringNamed(
|
||||
ClientKycApiConstants.exceptionReasonParam, element);
|
||||
final Boolean exceptionResolved = this.fromApiJsonHelper.extractBooleanNamed(
|
||||
ClientKycApiConstants.exceptionResolvedParam, element);
|
||||
|
||||
return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
|
||||
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin,
|
||||
aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason,
|
||||
exceptionResolved);
|
||||
aadhaarNumber, aadhaarRefNumber, aadhaarOtp);
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
|
||||
/**
|
||||
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
|
||||
* their pending profile-edit. Also records the tracking token, subject and body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
|
||||
* dialog can reproduce it later without regenerating the template.
|
||||
*
|
||||
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
|
||||
* that a mail-server outage never rolls back an already-committed approval.
|
||||
*/
|
||||
public interface ClientEditEmailService {
|
||||
|
||||
/**
|
||||
* Attempts to send a confirmation email to the client. On success, calls
|
||||
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
|
||||
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
|
||||
* {@code pendingEdit} — the approval itself is never rolled back.
|
||||
*
|
||||
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
|
||||
* yet persisted by the caller at this point)
|
||||
* @param client the client whose details were edited; used for display name and email
|
||||
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
|
||||
* {@code https://cbs.example.com}) used to build the tracking-pixel href
|
||||
* @param tenantId the current tenant identifier, used to build the pixel URL
|
||||
*/
|
||||
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
|
||||
* Checker. Records the tracking token, exact subject and exact body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
|
||||
* the entity after this method returns).
|
||||
*
|
||||
* <p>Template selection:
|
||||
* <ul>
|
||||
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
|
||||
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
|
||||
* {@code original_json}). Lists old→new values in a table.</li>
|
||||
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
|
||||
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
|
||||
* approval transaction to roll back. The caller catches any exception from this service and logs it.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
|
||||
|
||||
private static final String BANK_NAME = "CBSNexora";
|
||||
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
|
||||
|
||||
/** Contact fields that trigger Template A when they actually change. */
|
||||
private static final String FIELD_MOBILE = "mobileNo";
|
||||
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
|
||||
private static final String FIELD_EMAIL = "emailAddress";
|
||||
|
||||
/** Human-readable labels for every field that can appear in the email body — mirrors the
|
||||
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
|
||||
private static final Map<String, String> FIELD_LABELS;
|
||||
static {
|
||||
FIELD_LABELS = new LinkedHashMap<>();
|
||||
FIELD_LABELS.put("firstname", "First Name");
|
||||
FIELD_LABELS.put("middlename", "Middle Name");
|
||||
FIELD_LABELS.put("lastname", "Last Name");
|
||||
FIELD_LABELS.put("fullname", "Full Name");
|
||||
FIELD_LABELS.put("mobileNo", "Mobile Number");
|
||||
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
|
||||
FIELD_LABELS.put("emailAddress", "Email Address");
|
||||
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
|
||||
FIELD_LABELS.put("genderId", "Gender");
|
||||
FIELD_LABELS.put("maritalStatusId", "Marital Status");
|
||||
FIELD_LABELS.put("educationLevelId", "Education Level");
|
||||
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
|
||||
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
|
||||
FIELD_LABELS.put("occupationId", "Occupation");
|
||||
FIELD_LABELS.put("religionId", "Religion");
|
||||
FIELD_LABELS.put("socialCategoryId", "Social Category");
|
||||
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
|
||||
FIELD_LABELS.put("nationalityId", "Nationality");
|
||||
FIELD_LABELS.put("officeId", "Branch / Office");
|
||||
FIELD_LABELS.put("staffId", "Relationship Manager");
|
||||
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
|
||||
FIELD_LABELS.put("legalFormId", "Legal Form");
|
||||
FIELD_LABELS.put("salutationId", "Salutation");
|
||||
}
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
|
||||
|
||||
@Override
|
||||
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
|
||||
final String baseUrl, final String tenantId) {
|
||||
|
||||
final String toEmail = client.getEmailAddress();
|
||||
if (toEmail == null || toEmail.isBlank()) {
|
||||
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
|
||||
client.getId(), pendingEdit.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final String token = UUID.randomUUID().toString();
|
||||
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
|
||||
|
||||
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
|
||||
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
|
||||
|
||||
final boolean isContactChange = detectContactChange(payload, original);
|
||||
|
||||
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
|
||||
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
|
||||
|
||||
final String subject;
|
||||
final String body;
|
||||
if (isContactChange) {
|
||||
subject = "Your contact details have been updated — " + BANK_NAME;
|
||||
body = buildContactChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
} else {
|
||||
subject = "Your profile details have been updated — " + BANK_NAME;
|
||||
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
}
|
||||
|
||||
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
|
||||
|
||||
if (sent) {
|
||||
String fromEmail = "noreply@cbsnexora.in";
|
||||
try {
|
||||
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
|
||||
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
|
||||
fromEmail = smtp.getFromEmail();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
|
||||
}
|
||||
|
||||
pendingEdit.markEmailSent(token, subject, body, fromEmail);
|
||||
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
|
||||
} else {
|
||||
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
|
||||
}
|
||||
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
|
||||
pendingEdit.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Template detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if any of the three contact fields actually changed between
|
||||
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
|
||||
* falls back to false (Template B).
|
||||
*/
|
||||
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
|
||||
if (original == null) {
|
||||
return false;
|
||||
}
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String payloadVal = stringOf(payload, field);
|
||||
final String originalVal = stringOf(original, field);
|
||||
// Only counts as a change when both sides have a value and they differ, OR when
|
||||
// payload has a non-blank value and original didn't have the key at all.
|
||||
if (payloadVal != null && !payloadVal.equals(originalVal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Template A ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildContactChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder rows = new StringBuilder();
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String newVal = stringOf(payload, field);
|
||||
final String oldVal = original != null ? stringOf(original, field) : null;
|
||||
if (newVal == null) {
|
||||
continue;
|
||||
}
|
||||
if (oldVal != null && oldVal.equals(newVal)) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(field, field);
|
||||
rows.append("<tr>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
|
||||
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
|
||||
.append(escHtml(newVal)).append("</td>")
|
||||
.append("</tr>");
|
||||
}
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
|
||||
+ "<thead><tr>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
|
||||
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Template B ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder items = new StringBuilder();
|
||||
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
|
||||
final String key = entry.getKey();
|
||||
// Skip internal/non-display keys
|
||||
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
|
||||
continue;
|
||||
}
|
||||
final String payloadVal = stringOf(payload, key);
|
||||
final String originalVal = original != null ? stringOf(original, key) : null;
|
||||
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(key, key);
|
||||
items.append("<li>").append(escHtml(label)).append("</li>");
|
||||
}
|
||||
final String listHtml = items.length() > 0
|
||||
? "<ul style='padding-left:20px;'>" + items + "</ul>"
|
||||
: "<p><em>No specific fields identified.</em></p>";
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<p>The following fields were changed:</p>"
|
||||
+ listHtml
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
|
||||
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
|
||||
// without requiring a tenant header (it accepts the token and commits in the default
|
||||
// datasource context). However we embed it as a query param so future multi-tenant
|
||||
// support is easy to add without changing the stored URL.
|
||||
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
|
||||
}
|
||||
|
||||
private JsonObject parseJson(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final JsonElement el = JsonParser.parseString(json);
|
||||
return el.isJsonObject() ? el.getAsJsonObject() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject obj, final String key) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final JsonElement el = obj.get(key);
|
||||
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
||||
}
|
||||
|
||||
private String escHtml(final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
|
||||
|
||||
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult approveEdit(Long clientId);
|
||||
/**
|
||||
* Approves the pending edit and, on success, attempts to send a confirmation email to the
|
||||
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
|
||||
* can embed an absolute tracking-pixel URL in the email body.
|
||||
*
|
||||
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
|
||||
* @param tenantId current tenant identifier
|
||||
*/
|
||||
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
|
||||
|
||||
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||
}
|
||||
|
||||
+24
-1
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
|
||||
|
||||
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||
|
||||
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final ClientWritePlatformService clientWritePlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ClientEditEmailService clientEditEmailService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult approveEdit(final Long clientId) {
|
||||
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
|
||||
final AppUser checker = this.context.authenticatedUser();
|
||||
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||
|
||||
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
||||
// used, so every bit of Client's existing validation/change-detection logic runs exactly
|
||||
// as it would for an immediate edit — just deferred until a different user approves it.
|
||||
final Client client = pendingEdit.getClient();
|
||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
pendingEdit.approve(checker);
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
// Send confirmation email — silently swallowed on any failure so the approval is never
|
||||
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
|
||||
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
|
||||
// email tracking columns. If the send fails or the client has no email, this save is
|
||||
// a no-op (email_sent remains false).
|
||||
try {
|
||||
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
|
||||
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
|
||||
} else {
|
||||
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
|
||||
pendingEdit.getId(), e);
|
||||
}
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
||||
|
||||
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
for (ClientFamilyMembersData fm : familyMembers) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return familyMembers;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
||||
|
||||
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
if (fm != null) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
|
||||
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
|
||||
"c.gender_cv_id as genderId, cv.code_value as gender " +
|
||||
"from m_family_member_children c " +
|
||||
"left join m_code_value cv on c.gender_cv_id = cv.id " +
|
||||
"where c.family_member_id = ?";
|
||||
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
|
||||
.id(rs.getLong("id"))
|
||||
.fullName(rs.getString("fullName"))
|
||||
.genderId(JdbcSupport.getLong(rs, "genderId"))
|
||||
.gender(rs.getString("gender"))
|
||||
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
|
||||
.build();
|
||||
}, familyMemberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+41
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
|
||||
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
|
||||
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
if (childrenArray != null) {
|
||||
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
|
||||
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
|
||||
for (JsonElement childElement : childrenArray) {
|
||||
if (childElement.isJsonNull()) continue;
|
||||
JsonObject childObj = childElement.getAsJsonObject();
|
||||
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
|
||||
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
|
||||
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
|
||||
LocalDate dob = null;
|
||||
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
|
||||
try {
|
||||
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
|
||||
} catch (DateTimeParseException e) {
|
||||
LOG.warn("Could not parse dateOfBirth for child", e);
|
||||
}
|
||||
}
|
||||
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
|
||||
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
|
||||
}
|
||||
}
|
||||
}
|
||||
familyMember.setChildren(children);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateFamilyRequirementsForActivation(final Client client) {
|
||||
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
||||
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
||||
}
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
||||
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||
|
||||
if (member.has("children") && !member.get("children").isJsonNull()) {
|
||||
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
|
||||
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||
|
||||
}
|
||||
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||
clientFamilyMember.getRelationship(), familyMemberId);
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||
|
||||
-4
@@ -31,8 +31,4 @@ public interface ClientKycReadPlatformService {
|
||||
* Retrieve the KYC record for a client.
|
||||
*/
|
||||
ClientKycData retrieveClientKycByClientId(Long clientId);
|
||||
|
||||
java.util.List<ClientKycData> retrieveActiveKycExceptions();
|
||||
|
||||
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
|
||||
}
|
||||
|
||||
-29
@@ -19,8 +19,6 @@
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
@@ -57,19 +55,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
|
||||
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
|
||||
|
||||
final String mobileNo = kyc.getClient().getMobileNo();
|
||||
final String emailAddress = kyc.getClient().getEmailAddress();
|
||||
|
||||
return ClientKycData.builder()
|
||||
.id(kyc.getId())
|
||||
.clientId(kyc.getClient().getId())
|
||||
.clientDisplayName(kyc.getClient().getDisplayName())
|
||||
.clientAccountNo(kyc.getClient().getAccountNumber())
|
||||
.clientOfficeName(kyc.getClient().getOffice().getName())
|
||||
.clientMobileMasked(StringUtils.isNotBlank(mobileNo) ? OtpDestinationMasker.maskMobile(mobileNo) : null)
|
||||
.clientEmailMasked(StringUtils.isNotBlank(emailAddress) ? OtpDestinationMasker.maskEmail(emailAddress) : null)
|
||||
.hasMobile(StringUtils.isNotBlank(mobileNo))
|
||||
.hasEmail(StringUtils.isNotBlank(emailAddress))
|
||||
.kycStatus(status.getValue())
|
||||
.kycStatusCode(status.getCode())
|
||||
.kycType(type.getValue())
|
||||
@@ -114,10 +103,6 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.ckycNumber(kyc.getCkycNumber())
|
||||
.ckycUploadedOn(kyc.getCkycUploadedOn())
|
||||
.ckycBatchId(kyc.getCkycBatchId())
|
||||
.isException(kyc.getIsException())
|
||||
.exceptionReason(kyc.getExceptionReason())
|
||||
.exceptionResolved(kyc.getExceptionResolved())
|
||||
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
|
||||
.initiatedOn(kyc.getInitiatedOn())
|
||||
.verifiedOn(kyc.getVerifiedOn())
|
||||
.rejectedOn(kyc.getRejectedOn())
|
||||
@@ -125,18 +110,4 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.remarks(kyc.getRemarks())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveActiveKycExceptions() {
|
||||
return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveResolvedKycExceptions() {
|
||||
return this.kycRepository.findByExceptionResolvedTrue().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
|
||||
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
|
||||
*/
|
||||
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||
Integer limit);
|
||||
|
||||
/**
|
||||
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
|
||||
* edit — only populated once the approval email has been sent. Both {@code clientId} and
|
||||
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
|
||||
* when the record does not exist.
|
||||
*/
|
||||
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
|
||||
}
|
||||
|
||||
+37
-1
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
||||
pe.submitted_date AS submittedDate,
|
||||
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
JOIN m_appuser maker ON maker.id = pe.maker_id
|
||||
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
||||
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
|
||||
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
|
||||
final boolean emailSent = rs.getBoolean("emailSent");
|
||||
return ClientPendingEditData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
.checkerName(rs.getString("checkerName")) //
|
||||
.checkedDate(checkedDate) //
|
||||
.rejectReason(rs.getString("rejectReason")) //
|
||||
.emailSent(emailSent) //
|
||||
.emailSentAt(emailSentAt) //
|
||||
.emailOpenedAt(emailOpenedAt) //
|
||||
.build();
|
||||
};
|
||||
|
||||
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
|
||||
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
|
||||
final String sql = """
|
||||
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
|
||||
c.email_address AS clientEmail
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
WHERE pe.id = ? AND pe.client_id = ?
|
||||
""";
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
||||
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("emailSubject", rs.getString("emailSubject"));
|
||||
result.put("emailBody", rs.getString("emailBody"));
|
||||
result.put("emailSent", rs.getBoolean("emailSent"));
|
||||
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
|
||||
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
|
||||
result.put("emailFrom", rs.getString("emailFrom"));
|
||||
result.put("emailTo", rs.getString("clientEmail"));
|
||||
return result;
|
||||
}, pendingEditId, clientId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -136,6 +137,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
private final CifNumberGenerator cifNumberGenerator;
|
||||
private final ClientContactOtpService clientContactOtpService;
|
||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -788,13 +790,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
* client onboarded without a mobile number is having one set for the first time) — there is no
|
||||
* existing verified channel to prove possession of in that case.
|
||||
*/
|
||||
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
|
||||
private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
|
||||
final String newValue) {
|
||||
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
||||
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
||||
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
||||
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
|
||||
|
||||
if (StringUtils.isNotBlank(oldValue)) {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
|
||||
final Integer docCount = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
|
||||
Integer.class, oldMobileExceptionDocId, clientId);
|
||||
if (docCount == null || docCount == 0) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.client.mobile.old.verification.exception.invalid.document",
|
||||
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
|
||||
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
|
||||
}
|
||||
} else {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
}
|
||||
}
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
||||
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
||||
@@ -862,7 +878,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||
final String oldValue = clientForUpdate.getMobileNo();
|
||||
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
||||
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldMobileNoForNotification = oldValue;
|
||||
@@ -879,7 +895,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||
final String oldValue = clientForUpdate.getEmailAddress();
|
||||
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldEmailAddressForNotification = oldValue;
|
||||
|
||||
-47
@@ -1,47 +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 java.util.List;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
|
||||
/**
|
||||
* Records — and, for the sending channels, actually transmits — a KYC officer's outreach to a
|
||||
* customer with an open KYC exception.
|
||||
*/
|
||||
public interface KycContactLogService {
|
||||
|
||||
/**
|
||||
* Log one contact attempt. For {@code SMS}/{@code EMAIL}/{@code WHATSAPP} this also sends the
|
||||
* notice to the customer; for the offline channels it only records what the officer already did.
|
||||
*
|
||||
* <p>The log row is persisted whether or not delivery succeeded — a gateway outage must not
|
||||
* discard the officer's notes.
|
||||
*
|
||||
* @param clientId customer the exception belongs to
|
||||
* @param rawChannel channel as submitted by the UI (label or enum name)
|
||||
* @param notes officer's summary; mandatory
|
||||
* @param officerName officer selected in the dialog
|
||||
* @return the persisted log, including its delivery outcome
|
||||
*/
|
||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
|
||||
|
||||
/** All contact attempts for one customer, oldest first. */
|
||||
List<KycContactLogData> retrieveForClient(Long clientId);
|
||||
}
|
||||
-206
@@ -1,206 +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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLog;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(KycContactLogServiceImpl.class);
|
||||
|
||||
private static final int NOTES_MAX_LENGTH = 1000;
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String bankName;
|
||||
|
||||
/** Days the customer is given to act, quoted in the notice body. */
|
||||
@Value("${fineract.notification.kyc-contact.response-days:15}")
|
||||
private int responseDays;
|
||||
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final ClientKycRepository kycRepository;
|
||||
private final ClientKycContactLogRepository contactLogRepository;
|
||||
private final CustomerNotificationDispatcher dispatcher;
|
||||
private final EmailTemplateService emailTemplateService;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
||||
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
|
||||
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.kycRepository = kycRepository;
|
||||
this.contactLogRepository = contactLogRepository;
|
||||
this.dispatcher = dispatcher;
|
||||
this.emailTemplateService = emailTemplateService;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
|
||||
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||
if (channel == null) {
|
||||
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
||||
}
|
||||
if (StringUtils.isBlank(notes)) {
|
||||
throw validationError("notes", "A summary of the contact is required.");
|
||||
}
|
||||
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
|
||||
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
|
||||
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
|
||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||
|
||||
if (channel.isPlatformSent()) {
|
||||
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||
final String plainBody = buildPlainBody(client, exceptionReason, channel);
|
||||
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
|
||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
|
||||
: null;
|
||||
|
||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
||||
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
|
||||
result.failureReason(), plainBody);
|
||||
|
||||
if (!result.isDelivered()) {
|
||||
// Logged, not thrown: the officer's notes are still worth persisting, and the UI
|
||||
// surfaces the failure so nobody assumes the customer was reached.
|
||||
LOG.warn("KYC contact notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel,
|
||||
result.status(), result.failureReason());
|
||||
}
|
||||
}
|
||||
|
||||
final ClientKycContactLog saved = this.contactLogRepository.saveAndFlush(log);
|
||||
return toData(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
||||
return this.contactLogRepository.findByClientIdOrderByContactedAtAsc(clientId).stream().map(this::toData).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice text. Kept short and channel-aware because an SMS over 160 GSM-7 characters is
|
||||
* billed and delivered as multiple segments; email can afford the longer framing but reuses the
|
||||
* same sentence so the audit trail shows one consistent message across channels.
|
||||
*/
|
||||
private String buildPlainBody(final Client client, final String exceptionReason, final KycContactChannel channel) {
|
||||
final String reason = StringUtils.isNotBlank(exceptionReason) ? exceptionReason : "KYC verification is incomplete";
|
||||
|
||||
if (channel == KycContactChannel.SMS) {
|
||||
return "Dear " + displayName(client) + ", your KYC is pending: " + reason + ". Please visit your branch with valid "
|
||||
+ "documents within " + this.responseDays + " days to avoid account restrictions. - " + this.bankName;
|
||||
}
|
||||
return "Dear " + displayName(client) + ", our records show that your KYC (Know Your Customer) verification is pending "
|
||||
+ "for the following reason: " + reason + ". As required by the Reserve Bank of India's KYC Master Direction, "
|
||||
+ "please visit your home branch with valid identity and address documents within " + this.responseDays
|
||||
+ " days. If this is not completed, operations on your account may be restricted. "
|
||||
+ "If you have already submitted these documents, please ignore this message. - " + this.bankName;
|
||||
}
|
||||
|
||||
private KycContactLogData toData(final ClientKycContactLog log) {
|
||||
final KycContactChannel channel = log.getChannel();
|
||||
final KycContactDeliveryStatus status = log.getDeliveryStatus();
|
||||
|
||||
return KycContactLogData.builder()
|
||||
.id(log.getId())
|
||||
.clientId(log.getClient().getId())
|
||||
.kycId(log.getKycId())
|
||||
.channel(channel.name())
|
||||
.channelLabel(channel.getDisplayLabel())
|
||||
.notes(log.getNotes())
|
||||
.officerName(log.getOfficerName())
|
||||
.exceptionReason(log.getExceptionReason())
|
||||
.deliveryStatus(status.name())
|
||||
.delivered(status.isDelivered())
|
||||
.platformSent(channel.isPlatformSent())
|
||||
.deliveryMessage(describeDelivery(channel, status, log.getDestinationMasked(), log.getFailureReason()))
|
||||
.destinationMasked(log.getDestinationMasked())
|
||||
.provider(log.getProvider())
|
||||
.providerReference(log.getProviderReference())
|
||||
.messageBody(log.getMessageBody())
|
||||
.contactedAt(log.getContactedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Officer-facing outcome sentence. Failures name the cause so the next action is obvious. */
|
||||
private String describeDelivery(final KycContactChannel channel, final KycContactDeliveryStatus status, final String destination,
|
||||
final String failureReason) {
|
||||
return switch (status) {
|
||||
case NOT_APPLICABLE -> channel.getDisplayLabel() + " recorded.";
|
||||
case SENT -> channel.getDisplayLabel() + " sent to " + (StringUtils.isNotBlank(destination) ? destination : "the customer")
|
||||
+ ".";
|
||||
case SKIPPED_NO_DESTINATION -> "Not sent — the customer has no "
|
||||
+ (channel == KycContactChannel.EMAIL ? "email address" : "mobile number") + " on file.";
|
||||
case PROVIDER_NOT_CONFIGURED -> "Not sent — no " + channel.getDisplayLabel()
|
||||
+ " gateway is configured. The attempt has been logged.";
|
||||
case FAILED -> "Not sent — the gateway rejected the message"
|
||||
+ (StringUtils.isNotBlank(failureReason) ? " (" + failureReason + ")" : "") + ". The attempt has been logged.";
|
||||
};
|
||||
}
|
||||
|
||||
private String displayName(final Client client) {
|
||||
if (StringUtils.isNotBlank(client.getFirstname())) {
|
||||
return client.getFirstname();
|
||||
}
|
||||
if (StringUtils.isNotBlank(client.getDisplayName())) {
|
||||
return client.getDisplayName();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
|
||||
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.contact.log." + parameter + ".invalid",
|
||||
message, parameter);
|
||||
return new PlatformApiDataValidationException(List.of(error));
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -19,8 +19,12 @@
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
@@ -33,14 +37,18 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
|
||||
|
||||
private final ClientRiskRuleRepository repository;
|
||||
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<KycRiskRuleData> retrieveAllRules() {
|
||||
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
|
||||
return rules.stream().map(this::mapToData).collect(Collectors.toList());
|
||||
final Map<Long, AmlTransactionRule> txnRulesBySourceId = this.amlTransactionRuleRepository.findAll().stream()
|
||||
.filter(txn -> txn.getSourceClientRiskRuleId() != null)
|
||||
.collect(Collectors.toMap(AmlTransactionRule::getSourceClientRiskRuleId, Function.identity(), (a, b) -> a));
|
||||
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule) {
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
|
||||
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
||||
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
||||
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
|
||||
@@ -67,7 +75,14 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
rule.getBannedEntityMatch(),
|
||||
rule.getWillfulDefaulter(),
|
||||
rule.getEcgcSalFlag(),
|
||||
rule.getDormantFlag()
|
||||
rule.getDormantFlag(),
|
||||
txnRule != null ? txnRule.getThresholdAmount() : null,
|
||||
txnRule != null ? txnRule.getRuleType().name() : null,
|
||||
txnRule != null ? txnRule.getWindowDays() : null,
|
||||
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
|
||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||
txnRule != null ? txnRule.getMaxLimitAmount() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -67,10 +67,6 @@ public class ShareAccountData implements Serializable, AccountData {
|
||||
|
||||
private Long savingsAccountId;
|
||||
|
||||
private Long linkedLoanId;
|
||||
|
||||
private String linkedLoanAccountNo;
|
||||
|
||||
private BigDecimal currentMarketPrice;
|
||||
|
||||
private Integer lockinPeriod;
|
||||
@@ -305,11 +301,6 @@ public class ShareAccountData implements Serializable, AccountData {
|
||||
this.currentMarketPrice = currentMarketPrice;
|
||||
}
|
||||
|
||||
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
|
||||
this.linkedLoanId = linkedLoanId;
|
||||
this.linkedLoanAccountNo = linkedLoanAccountNo;
|
||||
}
|
||||
|
||||
public void setDividends(Collection<ShareAccountDividendData> dividends) {
|
||||
this.dividends = dividends;
|
||||
}
|
||||
|
||||
-6
@@ -51,10 +51,4 @@ public class ShareAccountTransactionData implements Serializable {
|
||||
|
||||
private final BigDecimal amountPaid;
|
||||
|
||||
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
|
||||
* only for a redeem (withdrawal) transaction; null otherwise. */
|
||||
private final String resolutionNumber;
|
||||
|
||||
private final LocalDate meetingDate;
|
||||
|
||||
}
|
||||
|
||||
-19
@@ -147,12 +147,6 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccount", orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private Set<ShareAccountCharge> charges;
|
||||
|
||||
/** Optional loan this share account is held as margin/security against. A raw id (not a JPA
|
||||
* relation) since shareaccounts and loanaccount are otherwise unrelated aggregate roots in
|
||||
* this codebase — kept loosely coupled, resolved via a lookup where needed. */
|
||||
@Column(name = "linked_loan_id")
|
||||
private Long linkedLoanId;
|
||||
|
||||
@Transient
|
||||
protected boolean accountNumberRequiresAutoGeneration = false;
|
||||
|
||||
@@ -605,19 +599,6 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
||||
return this.activatedDate;
|
||||
}
|
||||
|
||||
public Long getLinkedLoanId() {
|
||||
return this.linkedLoanId;
|
||||
}
|
||||
|
||||
public boolean setLinkedLoanId(final Long linkedLoanId) {
|
||||
boolean returnValue = false;
|
||||
if (this.linkedLoanId == null || !this.linkedLoanId.equals(linkedLoanId)) {
|
||||
this.linkedLoanId = linkedLoanId;
|
||||
returnValue = true;
|
||||
}
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
public Integer status() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
-24
@@ -67,14 +67,6 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
/** Board Resolution Number and Date of Meeting authorising this transaction — required for a
|
||||
* share withdrawal (redeem), a governance requirement for cooperative-sector share capital. */
|
||||
@Column(name = "resolution_number")
|
||||
private String resolutionNumber;
|
||||
|
||||
@Column(name = "meeting_date")
|
||||
private LocalDate meetingDate;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
|
||||
|
||||
@@ -117,22 +109,6 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
return new ShareAccountTransaction(transactionDate, totalShares, shareValue, status, type, amount, null, amountPaid);
|
||||
}
|
||||
|
||||
public static ShareAccountTransaction createRedeemTransaction(final LocalDate transactionDate, final Long totalShares,
|
||||
final BigDecimal shareValue, final String resolutionNumber, final LocalDate meetingDate) {
|
||||
ShareAccountTransaction transaction = createRedeemTransaction(transactionDate, totalShares, shareValue);
|
||||
transaction.resolutionNumber = resolutionNumber;
|
||||
transaction.meetingDate = meetingDate;
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public String getResolutionNumber() {
|
||||
return this.resolutionNumber;
|
||||
}
|
||||
|
||||
public LocalDate getMeetingDate() {
|
||||
return this.meetingDate;
|
||||
}
|
||||
|
||||
public static ShareAccountTransaction createChargeTransaction(final LocalDate transactionDate, final ShareAccountCharge charge) {
|
||||
final Long totalShares = null;
|
||||
final BigDecimal unitPrice = null;
|
||||
|
||||
+3
-135
@@ -72,7 +72,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -94,8 +93,6 @@ public class ShareAccountDataSerializer {
|
||||
|
||||
private final SavingsAccountReadPlatformService savingsAccountReadPlatformService;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final Set<String> approvalParameters = new HashSet<>(
|
||||
Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.dateformat_paramname,
|
||||
ShareAccountApiConstants.approveddate_paramname, ShareAccountApiConstants.note_paramname));
|
||||
@@ -109,14 +106,13 @@ public class ShareAccountDataSerializer {
|
||||
|
||||
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
|
||||
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
|
||||
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
|
||||
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
|
||||
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname));
|
||||
|
||||
@Autowired
|
||||
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
|
||||
final ChargeRepositoryWrapper chargeRepository, final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||
final ClientRepositoryWrapper clientRepositoryWrapper, final ShareProductRepositoryWrapper shareProductRepository,
|
||||
final SavingsAccountReadPlatformService savingsAccountReadPlatformService, final JdbcTemplate jdbcTemplate) {
|
||||
final SavingsAccountReadPlatformService savingsAccountReadPlatformService) {
|
||||
this.platformSecurityContext = platformSecurityContext;
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
this.chargeRepository = chargeRepository;
|
||||
@@ -124,7 +120,6 @@ public class ShareAccountDataSerializer {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.shareProductRepository = shareProductRepository;
|
||||
this.savingsAccountReadPlatformService = savingsAccountReadPlatformService;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public ShareAccount validateAndCreate(JsonCommand jsonCommand) {
|
||||
@@ -318,13 +313,6 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
|
||||
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
|
||||
if (account.setLinkedLoanId(linkedLoanId)) {
|
||||
actualChanges.put(ShareAccountApiConstants.linkedloanid_paramname, linkedLoanId);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.savingsaccountid_paramname, element)) {
|
||||
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
|
||||
@@ -897,13 +885,6 @@ public class ShareAccountDataSerializer {
|
||||
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(sharesRequested).notNull()
|
||||
.longGreaterThanZero();
|
||||
// Governance requirement: a share withdrawal must be authorised by a Board Resolution.
|
||||
final String resolutionNumber = this.fromApiJsonHelper
|
||||
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
|
||||
final LocalDate meetingDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname,
|
||||
element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
|
||||
boolean isTransactionBeforeExistingTransactions = false;
|
||||
isTransactionBeforeExistingTransactions = isTransactionBeforeExistingTransactions(requestedDate,
|
||||
isTransactionBeforeExistingTransactions, account);
|
||||
@@ -915,8 +896,7 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(requestedDate);
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice,
|
||||
resolutionNumber, meetingDate);
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice);
|
||||
validateRedeemRequest(account, transaction, baseDataValidator, dataValidationErrors);
|
||||
account.addAdditionalPurchasedShares(transaction);
|
||||
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
|
||||
@@ -937,18 +917,6 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
final Long remainingShares = account.getTotalApprovedShares() - redeemTransaction.getTotalShares();
|
||||
|
||||
validateAgainstLinkedLoan(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
validateMinimumSharesAndTenure(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
LocalDate redeemDate = redeemTransaction.getPurchasedDate();
|
||||
final Integer lockinPeriod = account.getLockinPeriodFrequency();
|
||||
final PeriodFrequencyType periodType = account.getLockinPeriodFrequencyType();
|
||||
@@ -999,63 +967,6 @@ public class ShareAccountDataSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
/** If this share account is held as margin/security against a loan, the shares remaining
|
||||
* after this redemption (at current unit price) must still cover that loan's outstanding
|
||||
* balance. */
|
||||
private void validateAgainstLinkedLoan(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
|
||||
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long linkedLoanId = account.getLinkedLoanId();
|
||||
if (linkedLoanId == null) {
|
||||
return;
|
||||
}
|
||||
BigDecimal outstandingLoanBalance;
|
||||
try {
|
||||
outstandingLoanBalance = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(total_outstanding_derived, principal_disbursed_derived, 0) FROM m_loan WHERE id = ? AND loan_status_id = 300",
|
||||
BigDecimal.class, linkedLoanId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
// Linked loan is no longer active (closed/written-off) — nothing left to protect.
|
||||
outstandingLoanBalance = null;
|
||||
}
|
||||
if (outstandingLoanBalance == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal remainingShareValue = redeemTransaction.getPurchasePrice().multiply(BigDecimal.valueOf(remainingShares));
|
||||
if (remainingShareValue.compareTo(outstandingLoanBalance) < 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
|
||||
.value(redeemTransaction.getTotalShares())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.shares.linked.to.active.loan.margin.shortfall");
|
||||
}
|
||||
}
|
||||
|
||||
/** If this redemption would bring the holding below the Share Product's configured minimum
|
||||
* client shares, the account must have been active for at least the product's configured
|
||||
* minimum active period before the reduction is allowed. */
|
||||
private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction,
|
||||
final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long minimumClientShares = account.getShareProduct().getMinimumClientShares();
|
||||
if (minimumClientShares == null || remainingShares >= minimumClientShares) {
|
||||
return;
|
||||
}
|
||||
final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency();
|
||||
final PeriodFrequencyType minimumActivePeriodType = account.getMinimumActivePeriodFrequencyType();
|
||||
final LocalDate activatedDate = account.getActivatedDate();
|
||||
if (minimumActivePeriod == null || minimumActivePeriodType == null || activatedDate == null) {
|
||||
// Product enforces a minimum balance but defines no minimum tenure — reducing below
|
||||
// minimum is simply not allowed at all in that case.
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname)
|
||||
.value(redeemTransaction.getTotalShares())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.required.by.product");
|
||||
return;
|
||||
}
|
||||
final LocalDate eligibleFromDate = deriveLockinPeriodDuration(minimumActivePeriod, minimumActivePeriodType, activatedDate);
|
||||
if (DateUtils.isAfter(eligibleFromDate, redeemTransaction.getPurchasedDate())) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname)
|
||||
.value(redeemTransaction.getPurchasedDate())
|
||||
.failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.before.minimum.tenure");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate deriveLockinPeriodDuration(final Integer lockinPeriod, final PeriodFrequencyType periodType, LocalDate purchaseDate) {
|
||||
LocalDate lockinDate = purchaseDate;
|
||||
if (periodType != null) {
|
||||
@@ -1133,11 +1044,6 @@ public class ShareAccountDataSerializer {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
validateNoActiveLoanOrGuaranteeBeforeClosure(account, baseDataValidator, dataValidationErrors);
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
AppUser approvedUser = this.platformSecurityContext.authenticatedUser();
|
||||
final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate());
|
||||
ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, account.getTotalApprovedShares(),
|
||||
@@ -1148,42 +1054,4 @@ public class ShareAccountDataSerializer {
|
||||
actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction);
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
/** A share account cannot be closed while the member has an active loan (share capital is
|
||||
* often required as continuing membership/security while a liability is outstanding) or is
|
||||
* standing as an active guarantor on someone else's active loan. */
|
||||
private void validateNoActiveLoanOrGuaranteeBeforeClosure(final ShareAccount account, final DataValidatorBuilder baseDataValidator,
|
||||
final List<ApiParameterError> dataValidationErrors) {
|
||||
final Long clientId = account.getClientId();
|
||||
Integer activeLoanCount;
|
||||
try {
|
||||
activeLoanCount = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id = 300", Integer.class, clientId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
activeLoanCount = 0;
|
||||
}
|
||||
if (activeLoanCount != null && activeLoanCount > 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
|
||||
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.loan");
|
||||
return;
|
||||
}
|
||||
|
||||
Integer activeGuaranteeCount;
|
||||
try {
|
||||
activeGuaranteeCount = this.jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(g.id)
|
||||
FROM m_guarantor g
|
||||
JOIN m_loan l ON l.id = g.loan_id
|
||||
WHERE g.entity_id = ?
|
||||
AND g.is_active = 1
|
||||
AND l.loan_status_id = 300
|
||||
""", Integer.class, clientId);
|
||||
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||
activeGuaranteeCount = 0;
|
||||
}
|
||||
if (activeGuaranteeCount != null && activeGuaranteeCount > 0) {
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname)
|
||||
.failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.guarantee");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -50,8 +50,7 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
||||
StringBuilder buff = new StringBuilder()
|
||||
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
|
||||
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
|
||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
|
||||
.append(" from m_share_account_transactions saps ");
|
||||
.append("saps.amount_paid as amountPaid").append(" from m_share_account_transactions saps ");
|
||||
schema = buff.toString();
|
||||
}
|
||||
|
||||
@@ -69,12 +68,9 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
||||
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amount");
|
||||
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "chargeamount");
|
||||
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amountPaid");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||
|
||||
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
||||
amount, chargeAmount, amountPaid);
|
||||
}
|
||||
|
||||
public String schema() {
|
||||
|
||||
+6
-15
@@ -267,7 +267,6 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
this.purchasedShares = purchasedShares;
|
||||
StringBuilder buff = new StringBuilder().append(
|
||||
"sa.id as id, sa.external_id as externalId, sa.resolution_number as resolutionNumber, sa.status_enum as statusEnum, ")
|
||||
.append("sa.linked_loan_id as linkedLoanId, ml.account_no as linkedLoanAccountNo, ")
|
||||
.append("sa.savings_account_id, msa.account_no as savingsAccNo, ")
|
||||
.append("c.id as clientId, c.display_name as clientName, ")
|
||||
.append("sa.account_no as accountNo, sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ")
|
||||
@@ -295,8 +294,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
.append("left join m_appuser abu on abu.id = sa.approved_userid ")
|
||||
.append("left join m_appuser avbu on rbu.id = sa.activated_userid ")
|
||||
.append("left join m_appuser cbu on cbu.id = sa.closed_userid ")
|
||||
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ")
|
||||
.append("left join m_loan ml on ml.id = sa.linked_loan_id ");
|
||||
.append("left join m_savings_account msa on sa.savings_account_id = msa.id ");
|
||||
this.schema = buff.toString();
|
||||
}
|
||||
|
||||
@@ -306,8 +304,6 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final String accountNo = rs.getString("accountNo");
|
||||
final String externalId = rs.getString("externalId");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final Long linkedLoanId = JdbcSupport.getLong(rs, "linkedLoanId");
|
||||
final String linkedLoanAccountNo = rs.getString("linkedLoanAccountNo");
|
||||
final Long savingsAccountId = JdbcSupport.getLong(rs, "savings_account_id");
|
||||
final String savingsAccountNumber = rs.getString("savingsAccNo");
|
||||
final Long clientId = JdbcSupport.getLong(rs, "clientId");
|
||||
@@ -379,12 +375,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final String shortProductName = null;
|
||||
final ShareAccountSummaryData summary = new ShareAccountSummaryData(id, accountNo, externalId, productId, productName,
|
||||
shortProductName, status, currency, totalApprovedShares, totalPendingShares, timeline);
|
||||
final ShareAccountData shareAccountData = new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId,
|
||||
savingsAccountNumber, clientId, clientName, productId, productName, status, timeline, currency, summary, charges,
|
||||
purchasedShares, lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
|
||||
return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId,
|
||||
clientName, productId, productName, status, timeline, currency, summary, charges, purchasedShares,
|
||||
lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType,
|
||||
allowdividendsforinactiveclients);
|
||||
shareAccountData.setLinkedLoan(linkedLoanId, linkedLoanAccountNo);
|
||||
return shareAccountData;
|
||||
|
||||
}
|
||||
|
||||
@@ -467,7 +461,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
StringBuilder buff = new StringBuilder().append(
|
||||
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
|
||||
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
|
||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
|
||||
.append("saps.amount_paid as amountPaid ");
|
||||
|
||||
schema = buff.toString();
|
||||
}
|
||||
@@ -486,11 +480,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
||||
final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amount");
|
||||
final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeamount");
|
||||
final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amountPaid");
|
||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
||||
amount, chargeAmount, amountPaid);
|
||||
}
|
||||
|
||||
public String schema() {
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
@@ -688,80 +688,6 @@ fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com
|
||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
||||
|
||||
# ── Customer notification gateways (KYC exception outreach) ──────────────────
|
||||
# Used by the "Log Customer Contact" action on the KYC Exceptions screen: choosing SMS, Email or
|
||||
# WhatsApp transmits a notice to the customer and records the delivery outcome on the contact log.
|
||||
#
|
||||
# Email needs no configuration here — it uses the spring.mail.* SMTP settings above.
|
||||
#
|
||||
# SMS and WhatsApp are vendor-agnostic: the request shape is described by properties so switching
|
||||
# gateway is a config change. `body-template` supports {{destination}}, {{message}}, {{senderId}}
|
||||
# and {{templateId}}; values are escaped for the configured content-type at send time.
|
||||
# Both are DISABLED by default — a channel that is off records the attempt and reports
|
||||
# "not sent, no gateway configured" rather than silently claiming success.
|
||||
#
|
||||
# NOTE: this is NOT the LincPay eKYC endpoint used for login OTP above. That endpoint mints an OTP
|
||||
# of its own and cannot carry an arbitrary message; point these at a real send-message API.
|
||||
#
|
||||
# Example (MSG91 transactional SMS):
|
||||
# fineract.notification.sms.base-url=https://control.msg91.com
|
||||
# fineract.notification.sms.path=/api/v5/flow/
|
||||
# fineract.notification.sms.auth-header-name=authkey
|
||||
# fineract.notification.sms.auth-header-value=<your authkey>
|
||||
# fineract.notification.sms.body-template={"template_id":"{{templateId}}","short_url":"0",\
|
||||
# "recipients":[{"mobiles":"{{destination}}","MESSAGE":"{{message}}"}]}
|
||||
# fineract.notification.sms.reference-json-path=data.messageId
|
||||
# ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL TEST GATEWAY — NOT A REAL SMS PROVIDER. │
|
||||
# │ │
|
||||
# │ These defaults point at scratchpad/mock-sms-gateway.js on localhost so │
|
||||
# │ the outreach flow can be demonstrated end to end. NOTHING REACHES A │
|
||||
# │ PHONE. Every contact-log row records provider=MOCK_SMS_GATEWAY_TEST_ONLY │
|
||||
# │ so a mock send can never be mistaken for a real delivery in the audit │
|
||||
# │ trail. │
|
||||
# │ │
|
||||
# │ BEFORE PRODUCTION: set FINERACT_NOTIFICATION_SMS_* to the real gateway, │
|
||||
# │ or set FINERACT_NOTIFICATION_SMS_ENABLED=false. Do not ship the mock. │
|
||||
# └──────────────────────────────────────────────────────────────────────────┘
|
||||
fineract.notification.sms.enabled=${FINERACT_NOTIFICATION_SMS_ENABLED:true}
|
||||
fineract.notification.sms.provider=${FINERACT_NOTIFICATION_SMS_PROVIDER:MOCK_SMS_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.sms.base-url=${FINERACT_NOTIFICATION_SMS_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.sms.path=${FINERACT_NOTIFICATION_SMS_PATH:/send}
|
||||
fineract.notification.sms.auth-header-name=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.sms.auth-header-value=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.sms.content-type=${FINERACT_NOTIFICATION_SMS_CONTENT_TYPE:application/json}
|
||||
fineract.notification.sms.body-template=${FINERACT_NOTIFICATION_SMS_BODY_TEMPLATE:{"mobileNumber":"{{destination}}","message":"{{message}}","senderId":"{{senderId}}","templateId":"{{templateId}}"\}}
|
||||
# DLT-registered sender ID and template ID — TRAI requires both for transactional SMS in India.
|
||||
fineract.notification.sms.sender-id=${FINERACT_NOTIFICATION_SMS_SENDER_ID:}
|
||||
fineract.notification.sms.template-id=${FINERACT_NOTIFICATION_SMS_TEMPLATE_ID:}
|
||||
fineract.notification.sms.reference-json-path=${FINERACT_NOTIFICATION_SMS_REFERENCE_JSON_PATH:data.messageId}
|
||||
|
||||
# Example (Meta WhatsApp Cloud API):
|
||||
# fineract.notification.whatsapp.base-url=https://graph.facebook.com
|
||||
# fineract.notification.whatsapp.path=/v21.0/<phone-number-id>/messages
|
||||
# fineract.notification.whatsapp.auth-header-value=Bearer <access token>
|
||||
# fineract.notification.whatsapp.body-template={"messaging_product":"whatsapp",\
|
||||
# "to":"{{destination}}","type":"text","text":{"body":"{{message}}"}}
|
||||
# fineract.notification.whatsapp.reference-json-path=messages.0.id
|
||||
# Business-initiated messages outside the 24h service window must use an approved template, so a
|
||||
# KYC notice will normally need the template-shaped body rather than the plain "text" one above.
|
||||
# Also pointed at the LOCAL TEST GATEWAY above — see that banner. Nothing reaches WhatsApp.
|
||||
fineract.notification.whatsapp.enabled=${FINERACT_NOTIFICATION_WHATSAPP_ENABLED:true}
|
||||
fineract.notification.whatsapp.provider=${FINERACT_NOTIFICATION_WHATSAPP_PROVIDER:MOCK_WHATSAPP_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.whatsapp.base-url=${FINERACT_NOTIFICATION_WHATSAPP_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.whatsapp.path=${FINERACT_NOTIFICATION_WHATSAPP_PATH:/send}
|
||||
fineract.notification.whatsapp.auth-header-name=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.whatsapp.auth-header-value=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.whatsapp.content-type=${FINERACT_NOTIFICATION_WHATSAPP_CONTENT_TYPE:application/json}
|
||||
fineract.notification.whatsapp.body-template=${FINERACT_NOTIFICATION_WHATSAPP_BODY_TEMPLATE:{"to":"{{destination}}","message":"{{message}}"\}}
|
||||
fineract.notification.whatsapp.sender-id=${FINERACT_NOTIFICATION_WHATSAPP_SENDER_ID:}
|
||||
fineract.notification.whatsapp.template-id=${FINERACT_NOTIFICATION_WHATSAPP_TEMPLATE_ID:}
|
||||
fineract.notification.whatsapp.reference-json-path=${FINERACT_NOTIFICATION_WHATSAPP_REFERENCE_JSON_PATH:data.messageId}
|
||||
fineract.notification.whatsapp.country-code=${FINERACT_NOTIFICATION_WHATSAPP_COUNTRY_CODE:91}
|
||||
|
||||
# Days the customer is given to complete KYC, quoted in the notice body.
|
||||
fineract.notification.kyc-contact.response-days=${FINERACT_NOTIFICATION_KYC_RESPONSE_DAYS:15}
|
||||
|
||||
# Frontend base URL — used for email links (voting, etc.)
|
||||
cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in}
|
||||
|
||||
|
||||
@@ -491,11 +491,13 @@
|
||||
<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/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0438_share_account_withdrawal_governance.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0439_create_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0440_seed_share_redemption_payment_type.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0444_add_kyc_exception_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_create_kyc_contact_log.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/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>
|
||||
|
||||
+16
-24
@@ -23,32 +23,24 @@
|
||||
|
||||
<!-- 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
|
||||
by "INTEREST PAYABLE" (gl_code 9.2).
|
||||
|
||||
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">
|
||||
by "INTEREST PAYABLE" (gl_code 9.2). -->
|
||||
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<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>
|
||||
<not>
|
||||
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
|
||||
</not>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO acc_gl_account (name, gl_code, parent_id, hierarchy, account_usage, classification_enum, disabled, manual_journal_entries_allowed, description)
|
||||
SELECT 'Sponsor DD Payable - SBI', '9.3', parent.id, '.', 1, 2, FALSE, TRUE,
|
||||
'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.'
|
||||
FROM acc_gl_account parent
|
||||
WHERE parent.gl_code = '9';
|
||||
</sql>
|
||||
<insert tableName="acc_gl_account">
|
||||
<column name="name" value="Sponsor DD Payable - SBI"/>
|
||||
<column name="gl_code" value="9.3"/>
|
||||
<column name="parent_id" valueNumeric="22"/>
|
||||
<column name="hierarchy" value="."/>
|
||||
<column name="account_usage" valueNumeric="1"/>
|
||||
<column name="classification_enum" valueNumeric="2"/>
|
||||
<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>
|
||||
|
||||
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
|
||||
|
||||
+13
-20
@@ -32,13 +32,6 @@
|
||||
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
|
||||
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">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
@@ -49,19 +42,19 @@
|
||||
VALUES
|
||||
('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),
|
||||
('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 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 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 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 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 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 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 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 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 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 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 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 AND aml_check_status IS NOT 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),
|
||||
('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_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),
|
||||
('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_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 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 LIMIT 1), 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),
|
||||
('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),
|
||||
('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),
|
||||
('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),
|
||||
('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),
|
||||
('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),
|
||||
('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);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!-- Optional risk-category-tiered thresholds. Null on both columns means the rule is not
|
||||
tiered and threshold_amount alone is used (unchanged behaviour for every pre-existing rule). -->
|
||||
<changeSet author="fineract" id="0438-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_medium_risk"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="threshold_amount_medium_risk" type="DECIMAL(19,6)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_aml_transaction_rule" columnName="threshold_amount_high_risk"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="threshold_amount_high_risk" type="DECIMAL(19,6)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- RTGS monitoring: Medium-risk clients are flagged at INR 5,00,000, High-risk at INR 20,00,000.
|
||||
threshold_amount (2,000,000) remains the fallback for Low-risk/unclassified clients. -->
|
||||
<changeSet author="fineract" id="0438-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'RTGS_HIGH_VALUE' AND threshold_amount_medium_risk IS NOT NULL</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule
|
||||
SET threshold_amount_medium_risk = 500000,
|
||||
threshold_amount_high_risk = 2000000
|
||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Rule 1 from the legacy Rules Parameter catalog: "Maintaining a low or overdrawn balance
|
||||
with high activity" (order_position 1). The legacy screen reports a single INR 10,00,000
|
||||
figure across three distinct parameters (average balance period, minimum balance, max
|
||||
transaction count), which does not correspond to any threshold/window shape this catalog
|
||||
currently evaluates (THRESHOLD/STRUCTURING/VELOCITY all key off a transaction AMOUNT, not
|
||||
an account balance level). Seeded here for catalog/display parity with m_client_risk_rule,
|
||||
with is_active = false so it is never picked up by AmlRuleEvaluationServiceImpl until a
|
||||
balance-based rule type is built. -->
|
||||
<changeSet author="fineract" id="0438-4">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_transaction_rule
|
||||
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
|
||||
VALUES
|
||||
('LOW_BALANCE_HIGH_ACTIVITY', 'Maintaining a low or overdrawn balance with high activity', 'SAVINGS', 1000000, 'THRESHOLD', null,
|
||||
(SELECT id FROM m_client_risk_rule WHERE order_position = 1 AND aml_check_status IS NOT NULL), false);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-60
@@ -1,60 +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">
|
||||
|
||||
<!--
|
||||
Share withdrawal governance: a share account may optionally be linked to a loan (held
|
||||
as margin/security) so a redeem-shares request can be checked against that loan's
|
||||
outstanding balance; a redeem transaction records the Board Resolution Number and Date
|
||||
of Meeting that authorised it.
|
||||
-->
|
||||
<changeSet author="fineract" id="0438-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_share_account" columnName="linked_loan_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_share_account">
|
||||
<column name="linked_loan_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="FK_m_share_account_linked_loan_id"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_share_account" baseColumnNames="linked_loan_id"
|
||||
referencedTableName="m_loan" referencedColumnNames="id"
|
||||
constraintName="FK_m_share_account_linked_loan_id" />
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0438-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_share_account_transactions" columnName="resolution_number"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_share_account_transactions">
|
||||
<column name="resolution_number" type="VARCHAR(100)"/>
|
||||
<column name="meeting_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-90
@@ -1,90 +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.1.xsd">
|
||||
|
||||
<!--
|
||||
Maker-checker for Share Account withdrawal (redeem shares) requests, submitted only from
|
||||
the Clerk Dashboard — mirrors m_clerk_withdrawal_request (cash withdrawal), but records the
|
||||
Board Resolution Number / Date of Meeting a share withdrawal is governed by, and links to a
|
||||
Share Account instead of a Savings/Current account.
|
||||
-->
|
||||
<changeSet id="0439_create_clerk_share_withdrawal_request" author="CBS">
|
||||
<createTable tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="share_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_no" type="VARCHAR(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(255)"/>
|
||||
<column name="requested_shares" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="requested_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution_number" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="TEXT"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="clerk_id" type="BIGINT"/>
|
||||
<column name="clerk_name" type="VARCHAR(255)"/>
|
||||
<column name="clerk_username" type="VARCHAR(100)"/>
|
||||
<column name="manager_id" type="BIGINT"/>
|
||||
<column name="manager_name" type="VARCHAR(255)"/>
|
||||
<column name="manager_username" type="VARCHAR(100)"/>
|
||||
<column name="rejection_reason" type="TEXT"/>
|
||||
<column name="share_transaction_id" type="VARCHAR(100)"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_clerk_id">
|
||||
<column name="clerk_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_submitted_at">
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_share_account_id">
|
||||
<column name="share_account_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0439-1">
|
||||
<validCheckSum>*</validCheckSum>
|
||||
<sql>
|
||||
-- 1. High Risk Occupations (Real Estate, Importer/Exporter, Commission Agent, Financial Advisor)
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 200000.0,
|
||||
min_annual_income = 2400000.0
|
||||
WHERE order_position IN (20, 30, 40, 50);
|
||||
|
||||
-- 2. Medium Risk Occupations
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 50000.0,
|
||||
max_monthly_income = 200000.0,
|
||||
min_annual_income = 600000.0,
|
||||
max_annual_income = 2400000.0
|
||||
WHERE order_position BETWEEN 90 AND 240;
|
||||
|
||||
-- 3. Low Risk Occupations
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = 0.0,
|
||||
max_monthly_income = 50000.0,
|
||||
min_annual_income = 0.0,
|
||||
max_annual_income = 600000.0
|
||||
WHERE order_position BETWEEN 280 AND 450;
|
||||
|
||||
-- 4. Set all remaining NULL values to 0.00
|
||||
UPDATE m_client_risk_rule SET min_monthly_income = 0.0 WHERE min_monthly_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_monthly_income = 0.0 WHERE max_monthly_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET min_annual_income = 0.0 WHERE min_annual_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_annual_income = 0.0 WHERE max_annual_income IS NULL;
|
||||
UPDATE m_client_risk_rule SET min_annual_turnover = 0.0 WHERE min_annual_turnover IS NULL;
|
||||
UPDATE m_client_risk_rule SET max_annual_turnover = 0.0 WHERE max_annual_turnover IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0440-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_client (
|
||||
account_no, external_id, status_enum, office_id, display_name,
|
||||
fullname, is_staff, activation_date, office_joining_date,
|
||||
submittedon_date, legal_form_enum,
|
||||
created_on_utc, created_by,
|
||||
last_modified_on_utc, last_modified_by,
|
||||
cif_number
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'ACC0000002', 'CLIENT_RAJESH', 300, 1, 'Rajesh Kumar',
|
||||
'Rajesh Kumar', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_RAJESH'
|
||||
),
|
||||
(
|
||||
'ACC0000003', 'CLIENT_SONIA', 300, 1, 'Sonia Sharma',
|
||||
'Sonia Sharma', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_SONIA'
|
||||
),
|
||||
(
|
||||
'ACC0000004', 'CLIENT_VIKRAM', 300, 1, 'Vikram Malhotra',
|
||||
'Vikram Malhotra', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 1,
|
||||
NOW(), 1,
|
||||
NOW(), 1,
|
||||
'CIF_VIKRAM'
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0440-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_aml_alert a
|
||||
JOIN m_client c ON a.client_id = c.id
|
||||
WHERE c.external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA', 'CLIENT_VIKRAM')
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_alert (
|
||||
transaction_type, transaction_id, client_id, rule_id, amount, currency_code,
|
||||
raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'SAVINGS', 1001,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'),
|
||||
3500000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
),
|
||||
(
|
||||
'SAVINGS', 1002,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'),
|
||||
995000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
),
|
||||
(
|
||||
'SAVINGS', 1003,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_VIKRAM'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'PEP_ACCOUNT_MONITORING'),
|
||||
75000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-32
@@ -1,32 +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">
|
||||
|
||||
<!-- Real-world UCB practice: a share redemption's value is never handed over as cash from the
|
||||
Share department — it is a book-transfer credited to the member's linked savings account
|
||||
(Dr Share Capital, Cr Savings Control). This is a deliberate, manual SECOND entry the clerk
|
||||
makes on the Transaction Entry screen (Deposit Slip on the linked savings account) after the
|
||||
Share Withdrawal Request is approved and shares are redeemed — the redemption itself never
|
||||
triggers this automatically. This Payment Type is the fund-source channel that drives correct
|
||||
GL routing for that manual deposit; an accountant maps it to the Share Capital GL account via
|
||||
the existing "Map Payment Types to Specific Funds" screen (we intentionally do NOT hardcode a
|
||||
GL account id here — see 0345's fix for why a tenant-specific GL id baked into a migration
|
||||
breaks on any tenant seeded differently). runOnChange lets this file's wording/flag tweaks
|
||||
safely re-validate on any environment where it already ran — the "not already seeded"
|
||||
precondition below is already false there, so it's skipped rather than re-inserted. -->
|
||||
<changeSet author="fineract" id="0440-1-seed-share-redemption-payment-type" runOnChange="true">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">select count(*) from m_payment_type where code_name = 'SHARE_REDEMPTION_TRANSFER'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_payment_type">
|
||||
<column name="value" value="Share Redemption Transfer"/>
|
||||
<column name="description" value="Manual book-transfer settlement of a share redemption to the member's linked savings account"/>
|
||||
<column name="is_cash_payment" valueBoolean="false"/>
|
||||
<column name="order_position" valueNumeric="0"/>
|
||||
<column name="code_name" value="SHARE_REDEMPTION_TRANSFER"/>
|
||||
<column name="is_system_defined" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
Declarative min/max band for each AML transaction rule, used by the AML Rules
|
||||
Master screen.
|
||||
|
||||
These are deliberately NOT the existing threshold_amount_medium_risk /
|
||||
threshold_amount_high_risk columns: those are live alerting overrides consumed by
|
||||
AmlRuleEvaluationServiceImpl.resolveThreshold(), and populating them purely to fill
|
||||
a display column would change which transactions raise alerts. These new columns
|
||||
are read-only documentation of the band a rule covers and never affect matching.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_aml_transaction_rule" columnName="min_limit_amount"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_aml_transaction_rule">
|
||||
<column name="min_limit_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="max_limit_amount" type="DECIMAL(19,6)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Band values. The minimum is the amount at which the rule starts to bite; the maximum
|
||||
is the amount beyond which the exposure is treated as materially more serious and
|
||||
escalated. A NULL maximum means the rule is genuinely open-ended.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-2">
|
||||
<sql>
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'CTR_THRESHOLD_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'STRUCTURED_CTR_10L_MONTHLY';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2500000, max_limit_amount = 5000000
|
||||
WHERE rule_code = 'LARGE_CASH_TXN_25L';
|
||||
-- Structuring rules watch the band immediately BELOW the reporting threshold.
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 700000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'STRUCTURING_BELOW_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 35000, max_limit_amount = 50000
|
||||
WHERE rule_code = 'STRUCTURING_BELOW_50K';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 900000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'FREQUENT_CASH_NEAR_10L';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'FREQUENT_LOW_CASH_DEPOSIT';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'FREQUENT_LOW_CASH_WITHDRAWAL';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 2000000
|
||||
WHERE rule_code = 'RTGS_HIGH_VALUE';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 2000000, max_limit_amount = 5000000
|
||||
WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 200000
|
||||
WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 50000, max_limit_amount = 500000
|
||||
WHERE rule_code = 'PEP_ACCOUNT_MONITORING';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 500000, max_limit_amount = 1000000
|
||||
WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 200000, max_limit_amount = 500000
|
||||
WHERE rule_code = 'LOAN_REPAYMENT_IN_CASH';
|
||||
UPDATE m_aml_transaction_rule SET min_limit_amount = 1000000, max_limit_amount = 2500000
|
||||
WHERE rule_code = 'LOW_BALANCE_HIGH_ACTIVITY';
|
||||
-- RAPID_MOVEMENT_IN_ACCOUNTS is a pure velocity rule with no monetary threshold,
|
||||
-- so it is intentionally left without a band.
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The three High-risk income rules were seeded with a floor but a zero ceiling, which the
|
||||
UI cannot distinguish from "not captured". Normalise the zeros to NULL so an absent
|
||||
ceiling reads as genuinely open-ended, and give the medium bands their missing floors.
|
||||
-->
|
||||
<changeSet author="fineract" id="0441-3">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule SET max_monthly_income = NULL WHERE max_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_income = NULL WHERE max_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_turnover = NULL WHERE max_annual_turnover = 0;
|
||||
UPDATE m_client_risk_rule SET min_monthly_income = NULL WHERE min_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_income = NULL WHERE min_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_turnover = NULL WHERE min_annual_turnover = 0;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-53
@@ -1,53 +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">
|
||||
|
||||
<!-- The clerk picks which Payment Type the settlement deposit (credit to the linked savings
|
||||
account) should use — this is captured at submit time, persisted here, validated (must not
|
||||
be a cash payment type — settlement is always a book-transfer), and then actually used at
|
||||
approval time instead of a hardcoded payment type lookup. Each column below is its own
|
||||
changeset with its own existence guard — an earlier, now-superseded revision of this file
|
||||
already created settlement_amount/settlement_savings_transaction_id on some environments
|
||||
(including local dev), so a single combined addColumn would fail wherever those two already
|
||||
exist but settlement_payment_type_id does not. -->
|
||||
<changeSet author="fineract" id="0441-1-add-settlement-payment-type-id">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_payment_type_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="settlement_payment_type_id" type="BIGINT">
|
||||
<constraints nullable="true" foreignKeyName="fk_clerk_share_wd_settlement_pt" references="m_payment_type(id)"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0441-2-add-settlement-amount">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_amount"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="settlement_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0441-3-add-settlement-savings-transaction-id">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_savings_transaction_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="settlement_savings_transaction_id" type="BIGINT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-23
@@ -1,23 +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">
|
||||
|
||||
<!-- Captures a full snapshot (as submitted JSON) of the CR/settlement row the clerk filled in on
|
||||
the "Enter Bank Transfer" screen — account no., ECS a/c no., amount, cheque no./date, token
|
||||
no., description, bank name, routing code — for audit purposes, alongside the columns that
|
||||
already exist for the DR (Share) row's own fields (resolution_number, meeting_date, etc). -->
|
||||
<changeSet author="fineract" id="0442-1-add-settlement-row-snapshot">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_row_snapshot"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_share_withdrawal_request">
|
||||
<column name="settlement_row_snapshot" type="TEXT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
Branch offices under Head Office.
|
||||
|
||||
Until now the tenant had a single office, so "see only my branch" had nothing to
|
||||
scope to. external_id carries the branch code used by the General Ledger and the
|
||||
RBI return screens, which is what lets a logged-in user's office be resolved to a
|
||||
branch without matching on display names.
|
||||
-->
|
||||
<changeSet author="fineract" id="0442-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006')</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_office (parent_id, hierarchy, external_id, name, opening_date)
|
||||
VALUES
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.2.', '0002', 'Andheri (E)', DATE '2018-04-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.3.', '0003', 'Bandra (W)', DATE '2018-04-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.4.', '0004', 'Borivali', DATE '2019-07-01'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.5.', '0005', 'Dadar', DATE '2020-01-02'),
|
||||
((SELECT id FROM m_office WHERE hierarchy = '.'), '.6.', '0006', 'Thane', DATE '2021-06-01');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Head Office carries the branch code the GL ledger uses for it. -->
|
||||
<changeSet author="fineract" id="0442-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id = 'HO'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
m_aml_alert.rule_id is NOT NULL, so an alert a user raises by hand from the ledger still
|
||||
needs a rule to hang off. MANUAL_REFERRAL is that rule.
|
||||
|
||||
It is seeded with is_active = false ON PURPOSE. AmlRuleEvaluationServiceImpl selects
|
||||
candidates with findByActiveTrueAndAppliesTo, so an active ALL-scoped rule with a null
|
||||
threshold would be evaluated against every live transaction. Inactive keeps it out of the
|
||||
engine entirely; the referral service loads it by rule_code instead.
|
||||
-->
|
||||
<changeSet author="fineract" id="0443-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_transaction_rule (rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
|
||||
VALUES ('MANUAL_REFERRAL', 'Marked suspicious by a user from the general ledger - no automated rule fired', 'ALL', null, 'THRESHOLD', null, null, false);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!-- Email confirmation tracking for the Client edit approval workflow.
|
||||
When a Clerk approves a pending client edit, a confirmation email is sent to the
|
||||
client's registered email address. These six columns record the outcome of that send
|
||||
and allow staff to see whether the client has opened the email (via a 1x1 tracking
|
||||
pixel whose URL embeds the UUID email_tracking_token).
|
||||
|
||||
email_subject / email_body store the exact content that was sent so a "View Email"
|
||||
dialog can show it later without regenerating the template.
|
||||
|
||||
email_tracking_token is a random UUID generated at send time — looked up directly by
|
||||
the public pixel endpoint (GET /api/*/clients/edit/pixel/{token}) so the endpoint
|
||||
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
|
||||
|
||||
<changeSet author="CBS" id="0444-1-add-email-tracking-token">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_tracking_token" type="VARCHAR(36)">
|
||||
<constraints nullable="true" unique="true"
|
||||
uniqueConstraintName="uq_client_pending_edit_email_token"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-2-add-email-sent">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-3-add-email-sent-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-4-add-email-opened-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-5-add-email-subject">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_subject" type="VARCHAR(300)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-6-add-email-body">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_body" type="TEXT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-45
@@ -1,45 +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="0444-kyc-is-exception" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="is_exception"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="is_exception" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-reason" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_reason"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_reason" type="VARCHAR(200)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-resolved" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_resolved" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-resolved-on" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved_on"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_resolved_on" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_from" type="VARCHAR(255)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-79
@@ -1,79 +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">
|
||||
|
||||
<!--
|
||||
Audit trail of every attempt a KYC officer makes to reach a customer about an open KYC
|
||||
exception. Two kinds of row land here and they must stay distinguishable:
|
||||
|
||||
* OFFLINE channels (PHONE_CALL / BRANCH_VISIT / FIELD_VISIT) — the officer is recording
|
||||
something that already happened away from the system. delivery_status is NOT_APPLICABLE.
|
||||
* SENDING channels (SMS / EMAIL / WHATSAPP) — the platform actually transmits a notice.
|
||||
delivery_status records what the gateway said, so a failed send can never be mistaken
|
||||
for a delivered one during an RBI inspection.
|
||||
-->
|
||||
<changeSet id="0445-create-client-kyc-contact-log" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_client_kyc_contact_log"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_client_kyc_contact_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="kyc_id" type="BIGINT"/>
|
||||
|
||||
<!-- PHONE_CALL | SMS | EMAIL | WHATSAPP | BRANCH_VISIT | FIELD_VISIT -->
|
||||
<column name="channel" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Free-text summary the officer typed. Mandatory: this is the compliance narrative. -->
|
||||
<column name="notes" type="VARCHAR(1000)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Officer as selected in the dialog, plus the authenticated user who really saved it. -->
|
||||
<column name="officer_name" type="VARCHAR(200)"/>
|
||||
<column name="logged_by_user_id" type="BIGINT"/>
|
||||
|
||||
<!-- Snapshot of the KYC exception reason at the time of contact. -->
|
||||
<column name="exception_reason" type="VARCHAR(200)"/>
|
||||
|
||||
<!-- NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED -->
|
||||
<column name="delivery_status" type="VARCHAR(30)" defaultValue="NOT_APPLICABLE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Masked destination only (+91-XXXXX-1234 / a****@x.com). Never store it unmasked. -->
|
||||
<column name="destination_masked" type="VARCHAR(120)"/>
|
||||
<column name="provider" type="VARCHAR(40)"/>
|
||||
<column name="provider_reference" type="VARCHAR(120)"/>
|
||||
<column name="failure_reason" type="VARCHAR(500)"/>
|
||||
<!-- Exact body transmitted, so the bank can prove what the customer was told. -->
|
||||
<column name="message_body" type="VARCHAR(2000)"/>
|
||||
|
||||
<column name="contacted_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_client_kyc_contact_log"
|
||||
baseColumnNames="client_id"
|
||||
constraintName="fk_kyc_contact_log_client"
|
||||
referencedTableName="m_client"
|
||||
referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_client">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_contacted_at">
|
||||
<column name="contacted_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet id="1" author="fineract">
|
||||
<createTable tableName="m_family_member_children">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="family_member_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="full_name" type="VARCHAR(255)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="gender_cv_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="date_of_birth" type="DATE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="family_member_id" constraintName="fk_family_member_children_family_member_id" referencedTableName="m_family_members" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="gender_cv_id" constraintName="fk_family_member_children_gender_cv_id" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+1
@@ -116,6 +116,7 @@
|
||||
<class>org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat</class>
|
||||
|
||||
<!-- Client Module Entities -->
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMembers</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientNonPerson</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientTransferDetails</class>
|
||||
|
||||
+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