diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index ab5b012e1..d3d8f9c15 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -241,6 +241,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "remarks", length = 500) private String remarks; + @Column(name = "is_exception", nullable = false) + private Boolean isException; + + @Column(name = "exception_reason", length = 200) + private String exceptionReason; + + @Column(name = "exception_resolved", nullable = false) + private Boolean exceptionResolved; + + @Column(name = "exception_resolved_on") + private LocalDate exceptionResolvedOn; + // ───────────────────────────────────────────────────────────────────────── protected ClientKyc() {} @@ -261,6 +273,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { this.gstinVerified = false; this.cinVerified = false; this.dinVerified = false; + this.isException = false; + this.exceptionResolved = false; this.initiatedOn = DateUtils.getLocalDateOfTenant(); } @@ -325,6 +339,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { command.stringValueOfParameterNamed("form15Type"), null); this.form15Fy = StringUtils.defaultIfEmpty( command.stringValueOfParameterNamed("form15FY"), null); + Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException"); + this.isException = isExceptionVal != null ? isExceptionVal : false; + this.exceptionReason = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("exceptionReason"), null); + Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved"); + this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false; + this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn"); } public Map update(final JsonCommand command) { @@ -398,6 +419,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { v -> this.form15Applicable = v); updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v); updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v); + updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false); + updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v); + updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false); + updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v); + updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false); + updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false); + updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false); return actualChanges; } @@ -795,6 +823,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { return form15Fy; } + public Boolean getIsException() { + return isException; + } + + public void setIsException(Boolean isException) { + this.isException = isException; + } + + public String getExceptionReason() { + return exceptionReason; + } + + public void setExceptionReason(String exceptionReason) { + this.exceptionReason = exceptionReason; + } + + public Boolean getExceptionResolved() { + return exceptionResolved; + } + + public void setExceptionResolved(Boolean exceptionResolved) { + this.exceptionResolved = exceptionResolved; + } + + public LocalDate getExceptionResolvedOn() { + return exceptionResolvedOn; + } + + public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) { + this.exceptionResolvedOn = exceptionResolvedOn; + } + @Column(name = "is_active", nullable = false) private Boolean isActive = Boolean.TRUE; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLog.java new file mode 100644 index 000000000..49ee6f528 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLog.java @@ -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. + * + *

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. + * + *

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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java new file mode 100644 index 000000000..e3da179b6 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycContactLogRepository.java @@ -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 { + + List 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 findByClientIds(@Param("clientIds") List clientIds); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java index 13bb92607..e7f1f9eee 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java @@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository { boolean existsByClientId(Long clientId); + List findByIsExceptionTrueAndExceptionResolvedFalse(); + + /** + * Resolved exceptions, keyed on {@code exceptionResolved} alone. + * + *

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 findByExceptionResolvedTrue(); + @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status") List findAllByStatus(@Param("status") Integer status); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactChannel.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactChannel.java new file mode 100644 index 000000000..86b273ebc --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactChannel.java @@ -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. + * + *

The critical distinction is {@link #isPlatformSent()}: + *

    + *
  • Offline channels ({@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.
  • + *
  • Sending channels ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to + * actually transmit a notice to the customer at save time.
  • + *
+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactDeliveryStatus.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactDeliveryStatus.java new file mode 100644 index 000000000..e2f635a07 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycContactDeliveryStatus.java @@ -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. + * + *

Every state other than {@link #SENT} means the customer was not 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java new file mode 100644 index 000000000..5f3ccae82 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycContactLogApiResource.java @@ -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. + * + *

+ *   POST /v1/kyc/exceptions/{clientId}/contact-log   body: {"channel":"SMS","notes":"...","officerName":"..."}
+ *   GET  /v1/kyc/exceptions/{clientId}/contact-log
+ * 
+ * + *

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. + * + *

No PUT or DELETE: the log is append-only evidence. + * + *

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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java new file mode 100644 index 000000000..9de9c6acc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java @@ -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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java index b3d5cb840..49aa8663f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java @@ -41,6 +41,9 @@ public class ClientKycCommand { private final Boolean verifyPan; private final Boolean verifyAadhaar; private final Boolean verifyGstin; + private final Boolean isException; + private final String exceptionReason; + private final Boolean exceptionResolved; // ── Aadhaar OTP flow ────────────────────────────────────────────────────── /** Full Aadhaar – used ONLY for OTP generation; never stored. */ @@ -54,7 +57,8 @@ public class ClientKycCommand { final String aadhaarNumberMasked, final String gstinNumber, final String remarks, final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar, final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber, - final String aadhaarOtp) { + final String aadhaarOtp, final Boolean isException, final String exceptionReason, + final Boolean exceptionResolved) { this.kycType = kycType; this.riskCategory = riskCategory; this.panNumber = panNumber; @@ -68,6 +72,9 @@ public class ClientKycCommand { this.aadhaarNumber = aadhaarNumber; this.aadhaarRefNumber = aadhaarRefNumber; this.aadhaarOtp = aadhaarOtp; + this.isException = isException; + this.exceptionReason = exceptionReason; + this.exceptionResolved = exceptionResolved; } 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 - // the Birth Certificate / School ID document-type picker instead. - if (!skipIdentityDocumentCheck) { + // the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception. + final boolean isKycException = isException != null && isException; + if (!skipIdentityDocumentCheck && !isKycException) { final boolean hasPan = panNumber != null && !panNumber.isBlank(); final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank(); final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank(); @@ -250,4 +258,16 @@ public class ClientKycCommand { public String getAadhaarRefNumber() { return aadhaarRefNumber; } /** OTP entered by the client. */ public String getAadhaarOtp() { return aadhaarOtp; } + + public Boolean getIsException() { + return isException; + } + + public String getExceptionReason() { + return exceptionReason; + } + + public Boolean getExceptionResolved() { + return exceptionResolved; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java index efdb192de..119152bff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java @@ -39,6 +39,9 @@ public final class ClientKycApiConstants { public static final String verifyPanParam = "verifyPan"; public static final String verifyAadhaarParam = "verifyAadhaar"; public static final String verifyGstinParam = "verifyGstin"; + public static final String panVerifiedParam = "panVerified"; + public static final String aadhaarVerifiedParam = "aadhaarVerified"; + public static final String gstinVerifiedParam = "gstinVerified"; // ── Aadhaar OTP flow parameters ─────────────────────────────────────────── /** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */ @@ -74,6 +77,10 @@ public final class ClientKycApiConstants { public static final String form15ApplicableParam = "form15Applicable"; public static final String form15TypeParam = "form15Type"; public static final String form15FYParam = "form15FY"; + public static final String isExceptionParam = "isException"; + public static final String exceptionReasonParam = "exceptionReason"; + public static final String exceptionResolvedParam = "exceptionResolved"; + public static final String exceptionResolvedOnParam = "exceptionResolvedOn"; // ── Supported parameters for initiate/update ────────────────────────────── public static final Set INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( @@ -83,16 +90,20 @@ public final class ClientKycApiConstants { kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, 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 UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( - panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, + kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, riskCategoryParam, remarksParam, "locale", "dateFormat", panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, - form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam)); + form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam, + isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam, + panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam)); /** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */ public static final Set VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java index 8eed2287d..aa45d0ea4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java @@ -97,10 +97,29 @@ public final class ClientKycData { private final LocalDate ckycUploadedOn; private final String ckycBatchId; + // ── Exception management ────────────────────────────────────────────────── + private final Boolean isException; + private final String exceptionReason; + private final Boolean exceptionResolved; + private final LocalDate exceptionResolvedOn; + // ── Lifecycle ───────────────────────────────────────────────────────────── private final LocalDate initiatedOn; private final LocalDate verifiedOn; private final LocalDate rejectedOn; private final LocalDate expiryDate; private final String remarks; + + private final String clientAccountNo; + private final String clientOfficeName; + + // ── Customer contact (for KYC outreach) ────────────────────────────────── + // Masked only. The exceptions screen shows these so the officer knows who they are reaching, + // but the full values never leave the server — the send path resolves them from m_client + // itself, so a tampered browser cannot redirect a KYC notice to another address. + private final String clientMobileMasked; + private final String clientEmailMasked; + /** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */ + private final Boolean hasMobile; + private final Boolean hasEmail; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycContactLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycContactLogData.java new file mode 100644 index 000000000..143220bf2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycContactLogData.java @@ -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. + * + *

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

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 sendersByChannel = new EnumMap<>(KycContactChannel.class); + + public CustomerNotificationDispatcher(final List 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationResult.java new file mode 100644 index 000000000..ac29d5c2d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationResult.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationSender.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationSender.java new file mode 100644 index 000000000..cab5e95d4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/CustomerNotificationSender.java @@ -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. + * + *

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. + * + *

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

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

Why this is template-driven rather than one class per vendor. 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. + * + *

The body template supports these placeholders, substituted with correct escaping for the + * configured content type: + *

    + *
  • {@code {{destination}}} — the customer's mobile number
  • + *
  • {@code {{message}}} — the notice text
  • + *
  • {@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number
  • + *
  • {@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one
  • + *
+ * + *

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

Note this is a separate 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/WhatsAppCustomerNotificationSender.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/WhatsAppCustomerNotificationSender.java new file mode 100644 index 000000000..50e90c1cd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/notification/WhatsAppCustomerNotificationSender.java @@ -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, …). + * + *

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. + * + *

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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java index d56b27270..419882260 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java @@ -76,8 +76,16 @@ public class ClientKycCommandFromApiJsonDeserializer { final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed( ClientKycApiConstants.aadhaarOtpParam, element); + final Boolean isException = this.fromApiJsonHelper.extractBooleanNamed( + ClientKycApiConstants.isExceptionParam, element); + final String exceptionReason = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.exceptionReasonParam, element); + final Boolean exceptionResolved = this.fromApiJsonHelper.extractBooleanNamed( + ClientKycApiConstants.exceptionResolvedParam, element); + return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked, gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin, - aadhaarNumber, aadhaarRefNumber, aadhaarOtp); + aadhaarNumber, aadhaarRefNumber, aadhaarOtp, isException, exceptionReason, + exceptionResolved); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java index b84937288..93c7e7569 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java @@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService { * Retrieve the KYC record for a client. */ ClientKycData retrieveClientKycByClientId(Long clientId); + + java.util.List retrieveActiveKycExceptions(); + + java.util.List retrieveResolvedKycExceptions(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index a64f7c18b..93f65baaa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -19,6 +19,8 @@ package org.apache.fineract.portfolio.client.service; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; import org.apache.fineract.portfolio.client.data.ClientKycData; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepository; @@ -55,10 +57,19 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue()); final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue()); + final String mobileNo = kyc.getClient().getMobileNo(); + final String emailAddress = kyc.getClient().getEmailAddress(); + return ClientKycData.builder() .id(kyc.getId()) .clientId(kyc.getClient().getId()) .clientDisplayName(kyc.getClient().getDisplayName()) + .clientAccountNo(kyc.getClient().getAccountNumber()) + .clientOfficeName(kyc.getClient().getOffice().getName()) + .clientMobileMasked(StringUtils.isNotBlank(mobileNo) ? OtpDestinationMasker.maskMobile(mobileNo) : null) + .clientEmailMasked(StringUtils.isNotBlank(emailAddress) ? OtpDestinationMasker.maskEmail(emailAddress) : null) + .hasMobile(StringUtils.isNotBlank(mobileNo)) + .hasEmail(StringUtils.isNotBlank(emailAddress)) .kycStatus(status.getValue()) .kycStatusCode(status.getCode()) .kycType(type.getValue()) @@ -103,6 +114,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .ckycNumber(kyc.getCkycNumber()) .ckycUploadedOn(kyc.getCkycUploadedOn()) .ckycBatchId(kyc.getCkycBatchId()) + .isException(kyc.getIsException()) + .exceptionReason(kyc.getExceptionReason()) + .exceptionResolved(kyc.getExceptionResolved()) + .exceptionResolvedOn(kyc.getExceptionResolvedOn()) .initiatedOn(kyc.getInitiatedOn()) .verifiedOn(kyc.getVerifiedOn()) .rejectedOn(kyc.getRejectedOn()) @@ -110,4 +125,18 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .remarks(kyc.getRemarks()) .build(); } + + @Override + public java.util.List retrieveActiveKycExceptions() { + return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream() + .map(this::toData) + .collect(java.util.stream.Collectors.toList()); + } + + @Override + public java.util.List retrieveResolvedKycExceptions() { + return this.kycRepository.findByExceptionResolvedTrue().stream() + .map(this::toData) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java new file mode 100644 index 000000000..11fe565e2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogService.java @@ -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. + * + *

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 retrieveForClient(Long clientId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java new file mode 100644 index 000000000..37cdfac7b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycContactLogServiceImpl.java @@ -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 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)); + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index f78e18d81..096798999 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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.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= +# 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//messages +# fineract.notification.whatsapp.auth-header-value=Bearer +# fineract.notification.whatsapp.body-template={"messaging_product":"whatsapp",\ +# "to":"{{destination}}","type":"text","text":{"body":"{{message}}"}} +# fineract.notification.whatsapp.reference-json-path=messages.0.id +# Business-initiated messages outside the 24h service window must use an approved template, so a +# KYC notice will normally need the template-shaped body rather than the plain "text" one above. +# Also pointed at the LOCAL TEST GATEWAY above — see that banner. Nothing reaches WhatsApp. +fineract.notification.whatsapp.enabled=${FINERACT_NOTIFICATION_WHATSAPP_ENABLED:true} +fineract.notification.whatsapp.provider=${FINERACT_NOTIFICATION_WHATSAPP_PROVIDER:MOCK_WHATSAPP_GATEWAY_TEST_ONLY} +fineract.notification.whatsapp.base-url=${FINERACT_NOTIFICATION_WHATSAPP_BASE_URL:http://127.0.0.1:9797} +fineract.notification.whatsapp.path=${FINERACT_NOTIFICATION_WHATSAPP_PATH:/send} +fineract.notification.whatsapp.auth-header-name=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_NAME:Authorization} +fineract.notification.whatsapp.auth-header-value=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_VALUE:mock-test-key} +fineract.notification.whatsapp.content-type=${FINERACT_NOTIFICATION_WHATSAPP_CONTENT_TYPE:application/json} +fineract.notification.whatsapp.body-template=${FINERACT_NOTIFICATION_WHATSAPP_BODY_TEMPLATE:{"to":"{{destination}}","message":"{{message}}"\}} +fineract.notification.whatsapp.sender-id=${FINERACT_NOTIFICATION_WHATSAPP_SENDER_ID:} +fineract.notification.whatsapp.template-id=${FINERACT_NOTIFICATION_WHATSAPP_TEMPLATE_ID:} +fineract.notification.whatsapp.reference-json-path=${FINERACT_NOTIFICATION_WHATSAPP_REFERENCE_JSON_PATH:data.messageId} +fineract.notification.whatsapp.country-code=${FINERACT_NOTIFICATION_WHATSAPP_COUNTRY_CODE:91} + +# Days the customer is given to complete KYC, quoted in the notice body. +fineract.notification.kyc-contact.response-days=${FINERACT_NOTIFICATION_KYC_RESPONSE_DAYS:15} + # Frontend base URL — used for email links (voting, etc.) cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index f701e4ed9..5b299e3c4 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -496,4 +496,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0444_add_kyc_exception_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0444_add_kyc_exception_fields.xml new file mode 100644 index 000000000..06123d6a3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0444_add_kyc_exception_fields.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0445_create_kyc_contact_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0445_create_kyc_contact_log.xml new file mode 100644 index 000000000..cd655e5ec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0445_create_kyc_contact_log.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +