kyc exception at client creation time and some tds work
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-08-05 17:27:56 +05:30
parent f1d68d17fe
commit baf30cff68
28 changed files with 2045 additions and 7 deletions
@@ -241,6 +241,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "remarks", length = 500) @Column(name = "remarks", length = 500)
private String remarks; 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() {} protected ClientKyc() {}
@@ -261,6 +273,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.gstinVerified = false; this.gstinVerified = false;
this.cinVerified = false; this.cinVerified = false;
this.dinVerified = false; this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant(); this.initiatedOn = DateUtils.getLocalDateOfTenant();
} }
@@ -325,6 +339,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("form15Type"), null); command.stringValueOfParameterNamed("form15Type"), null);
this.form15Fy = StringUtils.defaultIfEmpty( this.form15Fy = StringUtils.defaultIfEmpty(
command.stringValueOfParameterNamed("form15FY"), null); 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) { public Map<String, Object> update(final JsonCommand command) {
@@ -398,6 +419,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.form15Applicable = v); v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v); updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = 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; return actualChanges;
} }
@@ -795,6 +823,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return form15Fy; 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) @Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE; private Boolean isActive = Boolean.TRUE;
@@ -0,0 +1,204 @@
/**
* 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;
}
}
@@ -0,0 +1,36 @@
/**
* 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);
}
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
boolean existsByClientId(Long clientId); 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") @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status); List<ClientKyc> findAllByStatus(@Param("status") Integer status);
@@ -0,0 +1,91 @@
/**
* 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;
}
}
@@ -0,0 +1,48 @@
/**
* 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;
}
}
@@ -0,0 +1,111 @@
/**
* 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();
}
}
@@ -0,0 +1,73 @@
/**
* 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);
}
}
@@ -41,6 +41,9 @@ public class ClientKycCommand {
private final Boolean verifyPan; private final Boolean verifyPan;
private final Boolean verifyAadhaar; private final Boolean verifyAadhaar;
private final Boolean verifyGstin; private final Boolean verifyGstin;
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
// ── Aadhaar OTP flow ────────────────────────────────────────────────────── // ── Aadhaar OTP flow ──────────────────────────────────────────────────────
/** Full Aadhaar – used ONLY for OTP generation; never stored. */ /** Full Aadhaar – used ONLY for OTP generation; never stored. */
@@ -54,7 +57,8 @@ public class ClientKycCommand {
final String aadhaarNumberMasked, final String gstinNumber, final String remarks, final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar, final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber, final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
final String aadhaarOtp) { final String aadhaarOtp, final Boolean isException, final String exceptionReason,
final Boolean exceptionResolved) {
this.kycType = kycType; this.kycType = kycType;
this.riskCategory = riskCategory; this.riskCategory = riskCategory;
this.panNumber = panNumber; this.panNumber = panNumber;
@@ -68,6 +72,9 @@ public class ClientKycCommand {
this.aadhaarNumber = aadhaarNumber; this.aadhaarNumber = aadhaarNumber;
this.aadhaarRefNumber = aadhaarRefNumber; this.aadhaarRefNumber = aadhaarRefNumber;
this.aadhaarOtp = aadhaarOtp; this.aadhaarOtp = aadhaarOtp;
this.isException = isException;
this.exceptionReason = exceptionReason;
this.exceptionResolved = exceptionResolved;
} }
public void validateForInitiate() { public void validateForInitiate() {
@@ -111,8 +118,9 @@ public class ClientKycCommand {
} }
// At least one ID document must be provided — except for minors, who prove identity via // At least one ID document must be provided — except for minors, who prove identity via
// the Birth Certificate / School ID document-type picker instead. // the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
if (!skipIdentityDocumentCheck) { final boolean isKycException = isException != null && isException;
if (!skipIdentityDocumentCheck && !isKycException) {
final boolean hasPan = panNumber != null && !panNumber.isBlank(); final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank(); final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank(); final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
@@ -250,4 +258,16 @@ public class ClientKycCommand {
public String getAadhaarRefNumber() { return aadhaarRefNumber; } public String getAadhaarRefNumber() { return aadhaarRefNumber; }
/** OTP entered by the client. */ /** OTP entered by the client. */
public String getAadhaarOtp() { return aadhaarOtp; } public String getAadhaarOtp() { return aadhaarOtp; }
public Boolean getIsException() {
return isException;
}
public String getExceptionReason() {
return exceptionReason;
}
public Boolean getExceptionResolved() {
return exceptionResolved;
}
} }
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
public static final String verifyPanParam = "verifyPan"; public static final String verifyPanParam = "verifyPan";
public static final String verifyAadhaarParam = "verifyAadhaar"; public static final String verifyAadhaarParam = "verifyAadhaar";
public static final String verifyGstinParam = "verifyGstin"; 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 ─────────────────────────────────────────── // ── Aadhaar OTP flow parameters ───────────────────────────────────────────
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */ /** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
@@ -74,6 +77,10 @@ public final class ClientKycApiConstants {
public static final String form15ApplicableParam = "form15Applicable"; public static final String form15ApplicableParam = "form15Applicable";
public static final String form15TypeParam = "form15Type"; public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY"; 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 ────────────────────────────── // ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -83,16 +90,20 @@ public final class ClientKycApiConstants {
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam)); form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat", riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam)); form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */ /** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -97,10 +97,29 @@ public final class ClientKycData {
private final LocalDate ckycUploadedOn; private final LocalDate ckycUploadedOn;
private final String ckycBatchId; private final String ckycBatchId;
// ── Exception management ──────────────────────────────────────────────────
private final Boolean isException;
private final String exceptionReason;
private final Boolean exceptionResolved;
private final LocalDate exceptionResolvedOn;
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
private final LocalDate initiatedOn; private final LocalDate initiatedOn;
private final LocalDate verifiedOn; private final LocalDate verifiedOn;
private final LocalDate rejectedOn; private final LocalDate rejectedOn;
private final LocalDate expiryDate; private final LocalDate expiryDate;
private final String remarks; 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;
} }
@@ -0,0 +1,64 @@
/**
* 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;
}
@@ -0,0 +1,74 @@
/**
* 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);
}
}
@@ -0,0 +1,51 @@
/**
* 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();
}
}
@@ -0,0 +1,46 @@
/**
* 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);
}
@@ -0,0 +1,81 @@
/**
* 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());
}
}
}
@@ -0,0 +1,213 @@
/**
* 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;
}
}
}
@@ -0,0 +1,151 @@
/**
* 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);
}
}
@@ -0,0 +1,179 @@
/**
* 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;
}
}
@@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer {
final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed( final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed(
ClientKycApiConstants.aadhaarOtpParam, element); 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, return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked,
gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin, gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin,
aadhaarNumber, aadhaarRefNumber, aadhaarOtp); aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason,
exceptionResolved);
} }
} }
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
* Retrieve the KYC record for a client. * Retrieve the KYC record for a client.
*/ */
ClientKycData retrieveClientKycByClientId(Long clientId); ClientKycData retrieveClientKycByClientId(Long clientId);
java.util.List<ClientKycData> retrieveActiveKycExceptions();
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
} }
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import lombok.RequiredArgsConstructor; 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.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository; import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
@@ -55,10 +57,19 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue()); final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue());
final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue()); final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue());
final String mobileNo = kyc.getClient().getMobileNo();
final String emailAddress = kyc.getClient().getEmailAddress();
return ClientKycData.builder() return ClientKycData.builder()
.id(kyc.getId()) .id(kyc.getId())
.clientId(kyc.getClient().getId()) .clientId(kyc.getClient().getId())
.clientDisplayName(kyc.getClient().getDisplayName()) .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()) .kycStatus(status.getValue())
.kycStatusCode(status.getCode()) .kycStatusCode(status.getCode())
.kycType(type.getValue()) .kycType(type.getValue())
@@ -103,6 +114,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.ckycNumber(kyc.getCkycNumber()) .ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn()) .ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId()) .ckycBatchId(kyc.getCkycBatchId())
.isException(kyc.getIsException())
.exceptionReason(kyc.getExceptionReason())
.exceptionResolved(kyc.getExceptionResolved())
.exceptionResolvedOn(kyc.getExceptionResolvedOn())
.initiatedOn(kyc.getInitiatedOn()) .initiatedOn(kyc.getInitiatedOn())
.verifiedOn(kyc.getVerifiedOn()) .verifiedOn(kyc.getVerifiedOn())
.rejectedOn(kyc.getRejectedOn()) .rejectedOn(kyc.getRejectedOn())
@@ -110,4 +125,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.remarks(kyc.getRemarks()) .remarks(kyc.getRemarks())
.build(); .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());
}
} }
@@ -0,0 +1,47 @@
/**
* 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);
}
@@ -0,0 +1,206 @@
/**
* 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));
}
}
@@ -688,6 +688,80 @@ fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora} fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com} 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.) # Frontend base URL — used for email links (voting, etc.)
cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in} cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in}
@@ -496,4 +496,6 @@
<include file="parts/0440_seed_share_redemption_payment_type.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/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/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"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,45 @@
<?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>
@@ -0,0 +1,79 @@
<?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>