Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Customer-outreach log for KYC exceptions.
|
||||
*
|
||||
* <pre>
|
||||
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
|
||||
* GET /v1/kyc/exceptions/{clientId}/contact-log
|
||||
* </pre>
|
||||
*
|
||||
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
|
||||
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
|
||||
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
|
||||
* body with HTTP 200, not as an error, because the log row was still written.
|
||||
*
|
||||
* <p>No PUT or DELETE: the log is append-only evidence.
|
||||
*
|
||||
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
|
||||
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
|
||||
* already holds it.
|
||||
*/
|
||||
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycContactLogApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycContactLogService kycContactLogService;
|
||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Log a customer contact attempt for a KYC exception",
|
||||
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
|
||||
+ "customer using the destination held on their client record. Returns the persisted log including "
|
||||
+ "its delivery outcome.")
|
||||
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
|
||||
description = "All logged outreach for this customer, oldest first.")
|
||||
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
|
||||
return this.toApiJsonSerializer.serialize(logs);
|
||||
}
|
||||
|
||||
private JsonObject parse(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(json);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject payload, final String member) {
|
||||
final JsonElement element = payload.get(member);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
|
||||
@Path("/v1/kyc/exceptions")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycExceptionsApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientKycReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
|
||||
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
|
||||
this.context.authenticatedUser();
|
||||
final List<ClientKycData> exceptions;
|
||||
if (Boolean.TRUE.equals(resolved)) {
|
||||
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
|
||||
} else {
|
||||
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
|
||||
}
|
||||
for (ClientKycData k : exceptions) {
|
||||
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
|
||||
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
|
||||
}
|
||||
return this.toApiJsonSerializer.serialize(exceptions);
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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<String> 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<String> 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<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One KYC customer-contact attempt as returned to the UI.
|
||||
*
|
||||
* <p>{@code deliveryStatus} and {@code deliveryMessage} exist so the officer can tell a delivered
|
||||
* notice from a failed one at a glance — the dialog shows the failure verbatim rather than a
|
||||
* generic success toast.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public final class KycContactLogData {
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final Long kycId;
|
||||
|
||||
/** Enum name, e.g. {@code SMS}. */
|
||||
private final String channel;
|
||||
/** Human label, e.g. {@code SMS}/{@code In-Branch Visit} — what the grid renders. */
|
||||
private final String channelLabel;
|
||||
|
||||
private final String notes;
|
||||
private final String officerName;
|
||||
private final String exceptionReason;
|
||||
|
||||
/** NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED */
|
||||
private final String deliveryStatus;
|
||||
/** True only for a channel that was supposed to transmit and did. */
|
||||
private final Boolean delivered;
|
||||
/** True when this channel transmits at all (SMS/Email/WhatsApp) vs. is recorded offline. */
|
||||
private final Boolean platformSent;
|
||||
/** Officer-facing sentence describing the outcome — success or the reason it failed. */
|
||||
private final String deliveryMessage;
|
||||
|
||||
private final String destinationMasked;
|
||||
private final String provider;
|
||||
private final String providerReference;
|
||||
private final String messageBody;
|
||||
|
||||
private final LocalDateTime contactedAt;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Routes an outbound customer notice to the {@link CustomerNotificationSender} registered for its
|
||||
* channel.
|
||||
*
|
||||
* <p>Senders are injected as a list and indexed by channel at startup, so a new gateway becomes
|
||||
* available by declaring a bean — nothing here changes.
|
||||
*/
|
||||
@Component
|
||||
public class CustomerNotificationDispatcher {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CustomerNotificationDispatcher.class);
|
||||
|
||||
private final Map<KycContactChannel, CustomerNotificationSender> sendersByChannel = new EnumMap<>(KycContactChannel.class);
|
||||
|
||||
public CustomerNotificationDispatcher(final List<CustomerNotificationSender> senders) {
|
||||
for (final CustomerNotificationSender sender : senders) {
|
||||
final CustomerNotificationSender existing = this.sendersByChannel.put(sender.getChannel(), sender);
|
||||
if (existing != null) {
|
||||
LOG.warn("Two senders registered for channel {} — {} replaced {}", sender.getChannel(),
|
||||
sender.getClass().getSimpleName(), existing.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
LOG.info("Customer notification channels available: {}", this.sendersByChannel.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send on the given channel.
|
||||
*
|
||||
* @return the outcome; {@link CustomerNotificationResult#notConfigured} for an offline channel
|
||||
* or one with no registered sender. Never throws for a delivery failure.
|
||||
*/
|
||||
public CustomerNotificationResult dispatch(final KycContactChannel channel, final Client client, final String subject,
|
||||
final String plainBody, final String htmlBody) {
|
||||
if (!channel.isPlatformSent()) {
|
||||
// Caller shouldn't reach here for PHONE_CALL / visits, but guard rather than send.
|
||||
return CustomerNotificationResult.notConfigured(null, "Channel " + channel + " is recorded offline, not transmitted");
|
||||
}
|
||||
final CustomerNotificationSender sender = this.sendersByChannel.get(channel);
|
||||
if (sender == null) {
|
||||
LOG.warn("No sender registered for channel {} — notice to client {} not transmitted.", channel, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(null, "No gateway is registered for " + channel);
|
||||
}
|
||||
return sender.send(client, subject, plainBody, htmlBody);
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
|
||||
/**
|
||||
* Transport for one outbound customer channel.
|
||||
*
|
||||
* <p>Implementations are discovered by Spring and matched to a channel via {@link #getChannel()},
|
||||
* so adding a gateway means adding a bean — no dispatcher edit. Each implementation is responsible
|
||||
* for resolving the destination off the {@link Client} itself; callers never pass a destination in,
|
||||
* which keeps a browser from being able to redirect a KYC notice to an arbitrary address.
|
||||
*
|
||||
* <p>Contract: never throw for a delivery problem. Return
|
||||
* {@link CustomerNotificationResult#failed} instead, so the officer's contact log still persists.
|
||||
*/
|
||||
public interface CustomerNotificationSender {
|
||||
|
||||
KycContactChannel getChannel();
|
||||
|
||||
/**
|
||||
* @param client the customer to reach; the destination is read off this record
|
||||
* @param subject short title — used by channels that have one (email), ignored by those that don't
|
||||
* @param plainBody the message as plain text; SMS/WhatsApp send this verbatim
|
||||
* @param htmlBody rich rendering for channels that support it; may be null
|
||||
*/
|
||||
CustomerNotificationResult send(Client client, String subject, String plainBody, String htmlBody);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Email delivery via {@link EmailSenderService} — the direct-SMTP path configured from this app's
|
||||
* own {@code spring.mail.*} properties, the same one already used for OTP emails and client-change
|
||||
* alerts.
|
||||
*
|
||||
* <p>Deliberately not {@code PlatformEmailService}: that resolves SMTP credentials from the
|
||||
* DB-backed "external services" table used by the campaign pipeline, which is a separate and
|
||||
* typically unconfigured surface in this deployment.
|
||||
*/
|
||||
@Component
|
||||
public class EmailCustomerNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EmailCustomerNotificationSender.class);
|
||||
private static final String PROVIDER = "SMTP";
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
|
||||
public EmailCustomerNotificationSender(final EmailSenderService emailSenderService) {
|
||||
this.emailSenderService = emailSenderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String to = client.getEmailAddress();
|
||||
if (StringUtils.isBlank(to)) {
|
||||
LOG.warn("KYC contact email skipped — client {} has no email address on file.", client.getId());
|
||||
return CustomerNotificationResult.noDestination(PROVIDER);
|
||||
}
|
||||
|
||||
final String masked = OtpDestinationMasker.maskEmail(to);
|
||||
try {
|
||||
final boolean accepted = StringUtils.isNotBlank(htmlBody)
|
||||
? this.emailSenderService.sendHtml(to, subject, htmlBody)
|
||||
: this.emailSenderService.sendText(to, subject, plainBody);
|
||||
|
||||
if (accepted) {
|
||||
LOG.info("KYC contact email sent to client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.sent(masked, PROVIDER, null);
|
||||
}
|
||||
LOG.warn("KYC contact email rejected by SMTP for client {} at {}", client.getId(), masked);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, "SMTP rejected the message");
|
||||
} catch (final Exception e) {
|
||||
LOG.error("KYC contact email failed for client {} at {}", client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, PROVIDER, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.slf4j.Logger;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Shared HTTP plumbing for the SMS and WhatsApp gateways.
|
||||
*
|
||||
* <p><b>Why this is template-driven rather than one class per vendor.</b> Indian SMS/WhatsApp
|
||||
* gateways (MSG91, Gupshup, Kaleyra, Twilio, LincPay, …) all expose the same shape — POST a
|
||||
* destination plus a body to a URL with a static API key — but disagree on field names, auth header,
|
||||
* and whether the payload is JSON or form-encoded. Encoding those three differences as properties
|
||||
* means switching or adding a vendor is a config change, not a code change, and no vendor's
|
||||
* credentials end up hard-coded in the repo.
|
||||
*
|
||||
* <p>The body template supports these placeholders, substituted with correct escaping for the
|
||||
* configured content type:
|
||||
* <ul>
|
||||
* <li>{@code {{destination}}} — the customer's mobile number</li>
|
||||
* <li>{@code {{message}}} — the notice text</li>
|
||||
* <li>{@code {{senderId}}} — the DLT-registered sender ID / WhatsApp source number</li>
|
||||
* <li>{@code {{templateId}}} — the DLT / WhatsApp template ID, where the vendor requires one</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>TRAI's DLT regime means a transactional SMS to an Indian number is only accepted when the body
|
||||
* matches a pre-registered template, so {@code templateId} is exposed even though not every vendor
|
||||
* needs it.
|
||||
*/
|
||||
abstract class RestGatewayNotificationSender implements CustomerNotificationSender {
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
protected abstract Logger log();
|
||||
|
||||
/** Human-readable gateway name recorded on the contact log (e.g. {@code MSG91}). */
|
||||
protected abstract String providerName();
|
||||
|
||||
protected abstract boolean isEnabled();
|
||||
|
||||
protected abstract String baseUrl();
|
||||
|
||||
protected abstract String path();
|
||||
|
||||
protected abstract String authHeaderName();
|
||||
|
||||
protected abstract String authHeaderValue();
|
||||
|
||||
protected abstract String contentType();
|
||||
|
||||
protected abstract String bodyTemplate();
|
||||
|
||||
protected abstract String senderId();
|
||||
|
||||
protected abstract String templateId();
|
||||
|
||||
/**
|
||||
* Dotted path into the JSON response holding the vendor's message ID (e.g.
|
||||
* {@code data.messageId}). Blank when the vendor returns no usable reference.
|
||||
*/
|
||||
protected abstract String referenceJsonPath();
|
||||
|
||||
/** Resolve the customer's destination for this channel off the client record. */
|
||||
protected abstract String resolveDestination(Client client);
|
||||
|
||||
/** Mask the destination for storage and logging. */
|
||||
protected abstract String maskDestination(String destination);
|
||||
|
||||
@Override
|
||||
public CustomerNotificationResult send(final Client client, final String subject, final String plainBody, final String htmlBody) {
|
||||
final String provider = providerName();
|
||||
|
||||
if (!isEnabled()) {
|
||||
log().warn("{} notice not sent for client {} — channel disabled in configuration.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider,
|
||||
"Channel is disabled — set the corresponding fineract.notification.*.enabled property to true");
|
||||
}
|
||||
if (StringUtils.isBlank(baseUrl()) || StringUtils.isBlank(authHeaderValue())) {
|
||||
log().warn("{} notice not sent for client {} — gateway URL or credential missing.", provider, client.getId());
|
||||
return CustomerNotificationResult.notConfigured(provider, "Gateway base-url or API credential is not configured");
|
||||
}
|
||||
|
||||
final String destination = resolveDestination(client);
|
||||
if (StringUtils.isBlank(destination)) {
|
||||
log().warn("{} notice skipped — client {} has no destination on file for this channel.", provider, client.getId());
|
||||
return CustomerNotificationResult.noDestination(provider);
|
||||
}
|
||||
|
||||
final String masked = maskDestination(destination);
|
||||
final boolean json = !MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(contentType());
|
||||
final String payload = renderBody(destination, plainBody, json);
|
||||
|
||||
try {
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(contentType()));
|
||||
if (StringUtils.isNotBlank(authHeaderName())) {
|
||||
headers.set(authHeaderName(), authHeaderValue());
|
||||
}
|
||||
|
||||
final String base = baseUrl().endsWith("/") ? baseUrl().substring(0, baseUrl().length() - 1) : baseUrl();
|
||||
final String url = base + path();
|
||||
final ResponseEntity<String> response = this.restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
final String reference = extractReference(response.getBody());
|
||||
log().info("{} notice sent to client {} at {} (ref={})", provider, client.getId(), masked, reference);
|
||||
return CustomerNotificationResult.sent(masked, provider, reference);
|
||||
}
|
||||
// Non-2xx without an exception is unusual for RestTemplate but treat it as a failure
|
||||
// rather than assuming delivery — an over-optimistic SENT would corrupt the audit trail.
|
||||
log().warn("{} notice rejected for client {} at {} — HTTP {}", provider, client.getId(), masked, response.getStatusCode());
|
||||
return CustomerNotificationResult.failed(masked, provider, "Gateway returned HTTP " + response.getStatusCode());
|
||||
} catch (final Exception e) {
|
||||
log().error("{} notice failed for client {} at {}", provider, client.getId(), masked, e);
|
||||
return CustomerNotificationResult.failed(masked, provider, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the placeholders, escaping each value for the target encoding. Escaping happens per
|
||||
* value rather than on the finished string so that a message containing a quote or an ampersand
|
||||
* cannot break out of its field and corrupt the request.
|
||||
*/
|
||||
private String renderBody(final String destination, final String message, final boolean json) {
|
||||
String body = bodyTemplate();
|
||||
body = body.replace("{{destination}}", escape(destination, json));
|
||||
body = body.replace("{{message}}", escape(message, json));
|
||||
body = body.replace("{{senderId}}", escape(StringUtils.defaultString(senderId()), json));
|
||||
body = body.replace("{{templateId}}", escape(StringUtils.defaultString(templateId()), json));
|
||||
return body;
|
||||
}
|
||||
|
||||
private static String escape(final String value, final boolean json) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (!json) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
final StringBuilder out = new StringBuilder(value.length() + 16);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
final char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> out.append("\\\"");
|
||||
case '\\' -> out.append("\\\\");
|
||||
case '\n' -> out.append("\\n");
|
||||
case '\r' -> out.append("\\r");
|
||||
case '\t' -> out.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) {
|
||||
out.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
out.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** Walk {@link #referenceJsonPath()} through the response body; null if absent or unparseable. */
|
||||
private String extractReference(final String responseBody) {
|
||||
final String jsonPath = referenceJsonPath();
|
||||
if (StringUtils.isBlank(jsonPath) || StringUtils.isBlank(responseBody)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonElement node = JsonParser.parseString(responseBody);
|
||||
for (final String segment : jsonPath.split("\\.")) {
|
||||
if (node == null || !node.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject object = node.getAsJsonObject();
|
||||
if (!object.has(segment)) {
|
||||
return null;
|
||||
}
|
||||
node = object.get(segment);
|
||||
}
|
||||
return node != null && node.isJsonPrimitive() ? node.getAsString() : null;
|
||||
} catch (final Exception e) {
|
||||
log().debug("Could not read reference '{}' from {} response", jsonPath, providerName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Plain-text SMS via the configured gateway. See {@link RestGatewayNotificationSender} for why the
|
||||
* request shape is property-driven.
|
||||
*
|
||||
* <p>Note this is a <em>separate</em> gateway from the LincPay eKYC endpoints already configured
|
||||
* for login OTP ({@code /mobile/generate-otp}). That endpoint generates and delivers an OTP of
|
||||
* LincPay's own choosing; it cannot carry an arbitrary message, and routing a KYC notice through it
|
||||
* would mint a real, verifiable OTP the customer never requested. Configure a genuine
|
||||
* send-message endpoint here.
|
||||
*/
|
||||
@Component
|
||||
public class SmsCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SmsCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.sms.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.sms.provider:SMS_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.sms.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.sms.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.sms.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.sms.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.sms.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.sms.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.sms.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.sms.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.SMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return client.getMobileNo();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.notification;
|
||||
|
||||
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* WhatsApp Business delivery via the configured gateway (Meta Cloud API, Gupshup, Twilio, …).
|
||||
*
|
||||
* <p>Reuses the customer's registered mobile number as the WhatsApp destination — banks do not
|
||||
* capture a separate WhatsApp handle, and the Business API is keyed on the phone number anyway.
|
||||
* Most providers want it in E.164 form, so {@code fineract.notification.whatsapp.country-code} is
|
||||
* prefixed to bare 10-digit Indian numbers.
|
||||
*
|
||||
* <p>Meta's policy only permits business-initiated messages against an approved template outside the
|
||||
* 24-hour customer service window, which a KYC exception notice always is — so
|
||||
* {@code template-id} will normally need to be set alongside a template-shaped body.
|
||||
*/
|
||||
@Component
|
||||
public class WhatsAppCustomerNotificationSender extends RestGatewayNotificationSender {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WhatsAppCustomerNotificationSender.class);
|
||||
|
||||
@Value("${fineract.notification.whatsapp.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.provider:WHATSAPP_GATEWAY}")
|
||||
private String provider;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.path:}")
|
||||
private String path;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-name:Authorization}")
|
||||
private String authHeaderName;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.auth-header-value:}")
|
||||
private String authHeaderValue;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.content-type:application/json}")
|
||||
private String contentType;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.body-template:}")
|
||||
private String bodyTemplate;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.sender-id:}")
|
||||
private String senderId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.template-id:}")
|
||||
private String templateId;
|
||||
|
||||
@Value("${fineract.notification.whatsapp.reference-json-path:}")
|
||||
private String referenceJsonPath;
|
||||
|
||||
/** Prefixed to bare national numbers to reach E.164. Blank disables normalisation. */
|
||||
@Value("${fineract.notification.whatsapp.country-code:91}")
|
||||
private String countryCode;
|
||||
|
||||
@Override
|
||||
public KycContactChannel getChannel() {
|
||||
return KycContactChannel.WHATSAPP;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Logger log() {
|
||||
return LOG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String providerName() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String baseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String path() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderName() {
|
||||
return this.authHeaderName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String authHeaderValue() {
|
||||
return this.authHeaderValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String contentType() {
|
||||
return this.contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String bodyTemplate() {
|
||||
return this.bodyTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String senderId() {
|
||||
return this.senderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String templateId() {
|
||||
return this.templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String referenceJsonPath() {
|
||||
return this.referenceJsonPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveDestination(final Client client) {
|
||||
return toE164(client.getMobileNo());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String maskDestination(final String destination) {
|
||||
return OtpDestinationMasker.maskMobile(destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip formatting and prefix the country code when the number is stored nationally. A number
|
||||
* that already carries a country code (leading {@code +}, or longer than 10 digits) is passed
|
||||
* through so we never double-prefix.
|
||||
*/
|
||||
private String toE164(final String mobileNo) {
|
||||
if (mobileNo == null || mobileNo.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String digits = mobileNo.replaceAll("[^0-9+]", "");
|
||||
if (digits.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (digits.startsWith("+")) {
|
||||
return digits.substring(1);
|
||||
}
|
||||
if (this.countryCode == null || this.countryCode.isBlank() || digits.length() > 10) {
|
||||
return digits;
|
||||
}
|
||||
return this.countryCode + digits;
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -31,4 +31,8 @@ public interface ClientKycReadPlatformService {
|
||||
* Retrieve the KYC record for a client.
|
||||
*/
|
||||
ClientKycData retrieveClientKycByClientId(Long clientId);
|
||||
|
||||
java.util.List<ClientKycData> retrieveActiveKycExceptions();
|
||||
|
||||
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
|
||||
}
|
||||
|
||||
+29
@@ -19,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<ClientKycData> retrieveActiveKycExceptions() {
|
||||
return this.kycRepository.findByIsExceptionTrueAndExceptionResolvedFalse().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveResolvedKycExceptions() {
|
||||
return this.kycRepository.findByExceptionResolvedTrue().stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
|
||||
/**
|
||||
* Records — and, for the sending channels, actually transmits — a KYC officer's outreach to a
|
||||
* customer with an open KYC exception.
|
||||
*/
|
||||
public interface KycContactLogService {
|
||||
|
||||
/**
|
||||
* Log one contact attempt. For {@code SMS}/{@code EMAIL}/{@code WHATSAPP} this also sends the
|
||||
* notice to the customer; for the offline channels it only records what the officer already did.
|
||||
*
|
||||
* <p>The log row is persisted whether or not delivery succeeded — a gateway outage must not
|
||||
* discard the officer's notes.
|
||||
*
|
||||
* @param clientId customer the exception belongs to
|
||||
* @param rawChannel channel as submitted by the UI (label or enum name)
|
||||
* @param notes officer's summary; mandatory
|
||||
* @param officerName officer selected in the dialog
|
||||
* @return the persisted log, including its delivery outcome
|
||||
*/
|
||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
|
||||
|
||||
/** All contact attempts for one customer, oldest first. */
|
||||
List<KycContactLogData> retrieveForClient(Long clientId);
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLog;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(KycContactLogServiceImpl.class);
|
||||
|
||||
private static final int NOTES_MAX_LENGTH = 1000;
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String bankName;
|
||||
|
||||
/** Days the customer is given to act, quoted in the notice body. */
|
||||
@Value("${fineract.notification.kyc-contact.response-days:15}")
|
||||
private int responseDays;
|
||||
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final ClientKycRepository kycRepository;
|
||||
private final ClientKycContactLogRepository contactLogRepository;
|
||||
private final CustomerNotificationDispatcher dispatcher;
|
||||
private final EmailTemplateService emailTemplateService;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
||||
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
|
||||
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.kycRepository = kycRepository;
|
||||
this.contactLogRepository = contactLogRepository;
|
||||
this.dispatcher = dispatcher;
|
||||
this.emailTemplateService = emailTemplateService;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
|
||||
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||
if (channel == null) {
|
||||
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
||||
}
|
||||
if (StringUtils.isBlank(notes)) {
|
||||
throw validationError("notes", "A summary of the contact is required.");
|
||||
}
|
||||
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
|
||||
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
|
||||
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
|
||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||
|
||||
if (channel.isPlatformSent()) {
|
||||
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||
final String plainBody = buildPlainBody(client, exceptionReason, channel);
|
||||
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
|
||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
|
||||
: null;
|
||||
|
||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
||||
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
|
||||
result.failureReason(), plainBody);
|
||||
|
||||
if (!result.isDelivered()) {
|
||||
// Logged, not thrown: the officer's notes are still worth persisting, and the UI
|
||||
// surfaces the failure so nobody assumes the customer was reached.
|
||||
LOG.warn("KYC contact notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel,
|
||||
result.status(), result.failureReason());
|
||||
}
|
||||
}
|
||||
|
||||
final ClientKycContactLog saved = this.contactLogRepository.saveAndFlush(log);
|
||||
return toData(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
||||
return this.contactLogRepository.findByClientIdOrderByContactedAtAsc(clientId).stream().map(this::toData).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice text. Kept short and channel-aware because an SMS over 160 GSM-7 characters is
|
||||
* billed and delivered as multiple segments; email can afford the longer framing but reuses the
|
||||
* same sentence so the audit trail shows one consistent message across channels.
|
||||
*/
|
||||
private String buildPlainBody(final Client client, final String exceptionReason, final KycContactChannel channel) {
|
||||
final String reason = StringUtils.isNotBlank(exceptionReason) ? exceptionReason : "KYC verification is incomplete";
|
||||
|
||||
if (channel == KycContactChannel.SMS) {
|
||||
return "Dear " + displayName(client) + ", your KYC is pending: " + reason + ". Please visit your branch with valid "
|
||||
+ "documents within " + this.responseDays + " days to avoid account restrictions. - " + this.bankName;
|
||||
}
|
||||
return "Dear " + displayName(client) + ", our records show that your KYC (Know Your Customer) verification is pending "
|
||||
+ "for the following reason: " + reason + ". As required by the Reserve Bank of India's KYC Master Direction, "
|
||||
+ "please visit your home branch with valid identity and address documents within " + this.responseDays
|
||||
+ " days. If this is not completed, operations on your account may be restricted. "
|
||||
+ "If you have already submitted these documents, please ignore this message. - " + this.bankName;
|
||||
}
|
||||
|
||||
private KycContactLogData toData(final ClientKycContactLog log) {
|
||||
final KycContactChannel channel = log.getChannel();
|
||||
final KycContactDeliveryStatus status = log.getDeliveryStatus();
|
||||
|
||||
return KycContactLogData.builder()
|
||||
.id(log.getId())
|
||||
.clientId(log.getClient().getId())
|
||||
.kycId(log.getKycId())
|
||||
.channel(channel.name())
|
||||
.channelLabel(channel.getDisplayLabel())
|
||||
.notes(log.getNotes())
|
||||
.officerName(log.getOfficerName())
|
||||
.exceptionReason(log.getExceptionReason())
|
||||
.deliveryStatus(status.name())
|
||||
.delivered(status.isDelivered())
|
||||
.platformSent(channel.isPlatformSent())
|
||||
.deliveryMessage(describeDelivery(channel, status, log.getDestinationMasked(), log.getFailureReason()))
|
||||
.destinationMasked(log.getDestinationMasked())
|
||||
.provider(log.getProvider())
|
||||
.providerReference(log.getProviderReference())
|
||||
.messageBody(log.getMessageBody())
|
||||
.contactedAt(log.getContactedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Officer-facing outcome sentence. Failures name the cause so the next action is obvious. */
|
||||
private String describeDelivery(final KycContactChannel channel, final KycContactDeliveryStatus status, final String destination,
|
||||
final String failureReason) {
|
||||
return switch (status) {
|
||||
case NOT_APPLICABLE -> channel.getDisplayLabel() + " recorded.";
|
||||
case SENT -> channel.getDisplayLabel() + " sent to " + (StringUtils.isNotBlank(destination) ? destination : "the customer")
|
||||
+ ".";
|
||||
case SKIPPED_NO_DESTINATION -> "Not sent — the customer has no "
|
||||
+ (channel == KycContactChannel.EMAIL ? "email address" : "mobile number") + " on file.";
|
||||
case PROVIDER_NOT_CONFIGURED -> "Not sent — no " + channel.getDisplayLabel()
|
||||
+ " gateway is configured. The attempt has been logged.";
|
||||
case FAILED -> "Not sent — the gateway rejected the message"
|
||||
+ (StringUtils.isNotBlank(failureReason) ? " (" + failureReason + ")" : "") + ". The attempt has been logged.";
|
||||
};
|
||||
}
|
||||
|
||||
private String displayName(final Client client) {
|
||||
if (StringUtils.isNotBlank(client.getFirstname())) {
|
||||
return client.getFirstname();
|
||||
}
|
||||
if (StringUtils.isNotBlank(client.getDisplayName())) {
|
||||
return client.getDisplayName();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
|
||||
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.contact.log." + parameter + ".invalid",
|
||||
message, parameter);
|
||||
return new PlatformApiDataValidationException(List.of(error));
|
||||
}
|
||||
}
|
||||
@@ -688,6 +688,80 @@ fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com
|
||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
||||
|
||||
# ── Customer notification gateways (KYC exception outreach) ──────────────────
|
||||
# Used by the "Log Customer Contact" action on the KYC Exceptions screen: choosing SMS, Email or
|
||||
# WhatsApp transmits a notice to the customer and records the delivery outcome on the contact log.
|
||||
#
|
||||
# Email needs no configuration here — it uses the spring.mail.* SMTP settings above.
|
||||
#
|
||||
# SMS and WhatsApp are vendor-agnostic: the request shape is described by properties so switching
|
||||
# gateway is a config change. `body-template` supports {{destination}}, {{message}}, {{senderId}}
|
||||
# and {{templateId}}; values are escaped for the configured content-type at send time.
|
||||
# Both are DISABLED by default — a channel that is off records the attempt and reports
|
||||
# "not sent, no gateway configured" rather than silently claiming success.
|
||||
#
|
||||
# NOTE: this is NOT the LincPay eKYC endpoint used for login OTP above. That endpoint mints an OTP
|
||||
# of its own and cannot carry an arbitrary message; point these at a real send-message API.
|
||||
#
|
||||
# Example (MSG91 transactional SMS):
|
||||
# fineract.notification.sms.base-url=https://control.msg91.com
|
||||
# fineract.notification.sms.path=/api/v5/flow/
|
||||
# fineract.notification.sms.auth-header-name=authkey
|
||||
# fineract.notification.sms.auth-header-value=<your authkey>
|
||||
# fineract.notification.sms.body-template={"template_id":"{{templateId}}","short_url":"0",\
|
||||
# "recipients":[{"mobiles":"{{destination}}","MESSAGE":"{{message}}"}]}
|
||||
# fineract.notification.sms.reference-json-path=data.messageId
|
||||
# ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL TEST GATEWAY — NOT A REAL SMS PROVIDER. │
|
||||
# │ │
|
||||
# │ These defaults point at scratchpad/mock-sms-gateway.js on localhost so │
|
||||
# │ the outreach flow can be demonstrated end to end. NOTHING REACHES A │
|
||||
# │ PHONE. Every contact-log row records provider=MOCK_SMS_GATEWAY_TEST_ONLY │
|
||||
# │ so a mock send can never be mistaken for a real delivery in the audit │
|
||||
# │ trail. │
|
||||
# │ │
|
||||
# │ BEFORE PRODUCTION: set FINERACT_NOTIFICATION_SMS_* to the real gateway, │
|
||||
# │ or set FINERACT_NOTIFICATION_SMS_ENABLED=false. Do not ship the mock. │
|
||||
# └──────────────────────────────────────────────────────────────────────────┘
|
||||
fineract.notification.sms.enabled=${FINERACT_NOTIFICATION_SMS_ENABLED:true}
|
||||
fineract.notification.sms.provider=${FINERACT_NOTIFICATION_SMS_PROVIDER:MOCK_SMS_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.sms.base-url=${FINERACT_NOTIFICATION_SMS_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.sms.path=${FINERACT_NOTIFICATION_SMS_PATH:/send}
|
||||
fineract.notification.sms.auth-header-name=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.sms.auth-header-value=${FINERACT_NOTIFICATION_SMS_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.sms.content-type=${FINERACT_NOTIFICATION_SMS_CONTENT_TYPE:application/json}
|
||||
fineract.notification.sms.body-template=${FINERACT_NOTIFICATION_SMS_BODY_TEMPLATE:{"mobileNumber":"{{destination}}","message":"{{message}}","senderId":"{{senderId}}","templateId":"{{templateId}}"\}}
|
||||
# DLT-registered sender ID and template ID — TRAI requires both for transactional SMS in India.
|
||||
fineract.notification.sms.sender-id=${FINERACT_NOTIFICATION_SMS_SENDER_ID:}
|
||||
fineract.notification.sms.template-id=${FINERACT_NOTIFICATION_SMS_TEMPLATE_ID:}
|
||||
fineract.notification.sms.reference-json-path=${FINERACT_NOTIFICATION_SMS_REFERENCE_JSON_PATH:data.messageId}
|
||||
|
||||
# Example (Meta WhatsApp Cloud API):
|
||||
# fineract.notification.whatsapp.base-url=https://graph.facebook.com
|
||||
# fineract.notification.whatsapp.path=/v21.0/<phone-number-id>/messages
|
||||
# fineract.notification.whatsapp.auth-header-value=Bearer <access token>
|
||||
# fineract.notification.whatsapp.body-template={"messaging_product":"whatsapp",\
|
||||
# "to":"{{destination}}","type":"text","text":{"body":"{{message}}"}}
|
||||
# fineract.notification.whatsapp.reference-json-path=messages.0.id
|
||||
# Business-initiated messages outside the 24h service window must use an approved template, so a
|
||||
# KYC notice will normally need the template-shaped body rather than the plain "text" one above.
|
||||
# Also pointed at the LOCAL TEST GATEWAY above — see that banner. Nothing reaches WhatsApp.
|
||||
fineract.notification.whatsapp.enabled=${FINERACT_NOTIFICATION_WHATSAPP_ENABLED:true}
|
||||
fineract.notification.whatsapp.provider=${FINERACT_NOTIFICATION_WHATSAPP_PROVIDER:MOCK_WHATSAPP_GATEWAY_TEST_ONLY}
|
||||
fineract.notification.whatsapp.base-url=${FINERACT_NOTIFICATION_WHATSAPP_BASE_URL:http://127.0.0.1:9797}
|
||||
fineract.notification.whatsapp.path=${FINERACT_NOTIFICATION_WHATSAPP_PATH:/send}
|
||||
fineract.notification.whatsapp.auth-header-name=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_NAME:Authorization}
|
||||
fineract.notification.whatsapp.auth-header-value=${FINERACT_NOTIFICATION_WHATSAPP_AUTH_HEADER_VALUE:mock-test-key}
|
||||
fineract.notification.whatsapp.content-type=${FINERACT_NOTIFICATION_WHATSAPP_CONTENT_TYPE:application/json}
|
||||
fineract.notification.whatsapp.body-template=${FINERACT_NOTIFICATION_WHATSAPP_BODY_TEMPLATE:{"to":"{{destination}}","message":"{{message}}"\}}
|
||||
fineract.notification.whatsapp.sender-id=${FINERACT_NOTIFICATION_WHATSAPP_SENDER_ID:}
|
||||
fineract.notification.whatsapp.template-id=${FINERACT_NOTIFICATION_WHATSAPP_TEMPLATE_ID:}
|
||||
fineract.notification.whatsapp.reference-json-path=${FINERACT_NOTIFICATION_WHATSAPP_REFERENCE_JSON_PATH:data.messageId}
|
||||
fineract.notification.whatsapp.country-code=${FINERACT_NOTIFICATION_WHATSAPP_COUNTRY_CODE:91}
|
||||
|
||||
# Days the customer is given to complete KYC, quoted in the notice body.
|
||||
fineract.notification.kyc-contact.response-days=${FINERACT_NOTIFICATION_KYC_RESPONSE_DAYS:15}
|
||||
|
||||
# Frontend base URL — used for email links (voting, etc.)
|
||||
cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:https://cbs.godemo.in}
|
||||
|
||||
|
||||
@@ -496,4 +496,6 @@
|
||||
<include file="parts/0440_seed_share_redemption_payment_type.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0444_add_kyc_exception_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_create_kyc_contact_log.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="0444-kyc-is-exception" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="is_exception"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="is_exception" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-reason" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_reason"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_reason" type="VARCHAR(200)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-resolved" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_resolved" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0444-kyc-exception-resolved-on" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="exception_resolved_on"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="exception_resolved_on" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Audit trail of every attempt a KYC officer makes to reach a customer about an open KYC
|
||||
exception. Two kinds of row land here and they must stay distinguishable:
|
||||
|
||||
* OFFLINE channels (PHONE_CALL / BRANCH_VISIT / FIELD_VISIT) — the officer is recording
|
||||
something that already happened away from the system. delivery_status is NOT_APPLICABLE.
|
||||
* SENDING channels (SMS / EMAIL / WHATSAPP) — the platform actually transmits a notice.
|
||||
delivery_status records what the gateway said, so a failed send can never be mistaken
|
||||
for a delivered one during an RBI inspection.
|
||||
-->
|
||||
<changeSet id="0445-create-client-kyc-contact-log" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_client_kyc_contact_log"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_client_kyc_contact_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="kyc_id" type="BIGINT"/>
|
||||
|
||||
<!-- PHONE_CALL | SMS | EMAIL | WHATSAPP | BRANCH_VISIT | FIELD_VISIT -->
|
||||
<column name="channel" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Free-text summary the officer typed. Mandatory: this is the compliance narrative. -->
|
||||
<column name="notes" type="VARCHAR(1000)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Officer as selected in the dialog, plus the authenticated user who really saved it. -->
|
||||
<column name="officer_name" type="VARCHAR(200)"/>
|
||||
<column name="logged_by_user_id" type="BIGINT"/>
|
||||
|
||||
<!-- Snapshot of the KYC exception reason at the time of contact. -->
|
||||
<column name="exception_reason" type="VARCHAR(200)"/>
|
||||
|
||||
<!-- NOT_APPLICABLE | SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED -->
|
||||
<column name="delivery_status" type="VARCHAR(30)" defaultValue="NOT_APPLICABLE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Masked destination only (+91-XXXXX-1234 / a****@x.com). Never store it unmasked. -->
|
||||
<column name="destination_masked" type="VARCHAR(120)"/>
|
||||
<column name="provider" type="VARCHAR(40)"/>
|
||||
<column name="provider_reference" type="VARCHAR(120)"/>
|
||||
<column name="failure_reason" type="VARCHAR(500)"/>
|
||||
<!-- Exact body transmitted, so the bank can prove what the customer was told. -->
|
||||
<column name="message_body" type="VARCHAR(2000)"/>
|
||||
|
||||
<column name="contacted_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_client_kyc_contact_log"
|
||||
baseColumnNames="client_id"
|
||||
constraintName="fk_kyc_contact_log_client"
|
||||
referencedTableName="m_client"
|
||||
referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_client">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_client_kyc_contact_log" indexName="idx_kyc_contact_log_contacted_at">
|
||||
<column name="contacted_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user