diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index 599ff372a..6a92d6bab 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -96,6 +96,15 @@ public class ClientApiConstants { public static final String mobileNoParamName = "mobileNo"; public static final String alternateMobileNoParamName = "alternateMobileNo"; public static final String emailAddressParamName = "emailAddress"; + // Dual-OTP contact-change verification (see ClientContactOtpService) — required only when + // mobileNo or emailAddress is being changed via PUT /clients/{id}. Reference an already + // VERIFIED m_otp_log row for the OLD destination (current DB value) and the NEW destination + // (incoming value) respectively. Field-specific (not a single generic pair) so mobileNo and + // emailAddress can both change in the same request without ambiguity. + public static final String mobileOldOtpIdParamName = "mobileOldOtpId"; + public static final String mobileNewOtpIdParamName = "mobileNewOtpId"; + public static final String emailOldOtpIdParamName = "emailOldOtpId"; + public static final String emailNewOtpIdParamName = "emailNewOtpId"; public static final String firstnameParamName = "firstname"; public static final String middlenameParamName = "middlename"; public static final String lastnameParamName = "lastname"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java index 316c86bc0..66162c676 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressWritePlatformServiceImpl.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.client.domain.ClientAddress; import org.apache.fineract.portfolio.client.domain.ClientAddressRepository; import org.apache.fineract.portfolio.client.domain.ClientAddressRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.ClientChangeNotificationService; import org.springframework.stereotype.Service; @Service @@ -53,6 +54,7 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ private final AddressRepository addressRepository; private final ClientAddressRepositoryWrapper clientAddressRepositoryWrapper; private final AddressCommandFromApiJsonDeserializer fromApiJsonDeserializer; + private final ClientChangeNotificationService clientChangeNotificationService; @Override public CommandProcessingResult addClientAddress(final Long clientId, final Long addressTypeId, final JsonCommand command) { @@ -170,6 +172,10 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ final Address addobj = this.addressRepository.getReferenceById(addressId); + // Snapshot before mutation — notification-only (no OTP gate for address; see class Javadoc / + // feature plan). Compared against the post-update state below to build the change message. + final String oldAddressSummary = formatAddressSummary(addobj); + if (!command.stringValueOfParameterNamed("addressLine1").isEmpty()) { is_address_update = true; @@ -271,6 +277,38 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ } this.clientAddressRepository.saveAndFlush(clientAddressObj); + if (is_address_update) { + final String newAddressSummary = formatAddressSummary(addobj); + final Client client = clientAddressObj.getClient(); + this.clientChangeNotificationService.notifyContactOrAddressChanged(client, ClientChangeNotificationService.FIELD_LABEL_ADDRESS, + oldAddressSummary, newAddressSummary, client.getEmailAddress()); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientAddressObj.getId()).build(); } + + /** + * Human-readable, comma-joined summary of the address's free-text fields — used only for the + * change-notification email body (see {@link ClientChangeNotificationService}), not persisted. + */ + private String formatAddressSummary(final Address address) { + final StringBuilder sb = new StringBuilder(); + appendIfPresent(sb, address.getAddressLine1()); + appendIfPresent(sb, address.getAddressLine2()); + appendIfPresent(sb, address.getAddressLine3()); + appendIfPresent(sb, address.getTownVillage()); + appendIfPresent(sb, address.getCity()); + appendIfPresent(sb, address.getCountyDistrict()); + appendIfPresent(sb, address.getPostalCode()); + return sb.length() == 0 ? "(not set)" : sb.toString(); + } + + private void appendIfPresent(final StringBuilder sb, final String value) { + if (value != null && !value.isBlank()) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(value); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java new file mode 100644 index 000000000..9293cbf2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java @@ -0,0 +1,159 @@ +/** + * 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.contactverification.api; + +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.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +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.Context; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse; +import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.api.ClientApiConstants; +import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.springframework.stereotype.Component; + +/** + * Staff-facing endpoints used to gate a client's mobileNo / emailAddress change on + * {@code PUT /v1/clients/{clientId}} behind dual OTP verification (old destination AND new + * destination must each be OTP-verified before the update call will accept the change — see + * {@code ClientDataValidator} and {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}). + * + *
+ * OTP is entered by the staff member on the customer's behalf (no self-service customer portal + * exists in this deployment), which is why this resource requires a normal, fully-authenticated + * staff session rather than being public like the pre-login {@code /v1/login/otp/*} endpoints. + * + *
+ * Deliberately does NOT contain the literal path segment {@code /self/} anywhere — that substring + * causes {@code TenantAwareBasicAuthenticationFilter} to reject any non-self-service (staff) caller + * with a 401, which triggers a browser Basic-Auth popup. See the class-level {@code @Path} below. + * + *
+ * POST /v1/clients/{clientId}/contact-verification/otp body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
+ * POST /v1/clients/{clientId}/contact-verification/otp/verify body: {"otpId":123,"otp":"123456"}
+ *
+ */
+@Path("/v1/clients/{clientId}/contact-verification/otp")
+@Component
+@Tag(name = "Client Contact Verification",
+ description = "Dual-OTP verification of a client's old/new mobile number or email address, "
+ + "required before PUT /v1/clients/{clientId} will accept a mobileNo/emailAddress change.")
+@RequiredArgsConstructor
+public class ClientContactVerificationApiResource {
+
+ private final PlatformSecurityContext context;
+ private final ClientRepositoryWrapper clientRepositoryWrapper;
+ private final ClientContactOtpService clientContactOtpService;
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Generate an OTP against a client's mobile number or email address",
+ description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated "
+ + "6-digit code to `destination` via SMTP. Call this once for the client's *current* (old) "
+ + "destination and once for the *incoming* (new) destination before attempting the actual "
+ + "PUT /v1/clients/{clientId} update — both otpId's are required as mobileOldOtpId/mobileNewOtpId "
+ + "(or emailOldOtpId/emailNewOtpId) on that call.")
+ public OtpGenerateResponse generate(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson,
+ @Context final HttpServletRequest httpRequest) {
+
+ this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
+ this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
+
+ final JsonObject payload = parse(apiRequestBodyAsJson);
+ final String channel = stringOrNull(payload, "channel");
+ final String destination = stringOrNull(payload, "destination");
+
+ final String ipAddress = clientIp(httpRequest);
+ final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
+
+ if ("EMAIL".equalsIgnoreCase(channel)) {
+ return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent);
+ }
+ if ("MOBILE".equalsIgnoreCase(channel)) {
+ return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent);
+ }
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid",
+ "channel must be one of MOBILE, EMAIL", channel);
+ }
+
+ @POST
+ @Path("verify")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Verify a previously generated client-contact-change OTP")
+ public OtpVerifyResponse verify(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
+
+ this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
+ this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
+
+ final JsonObject payload = parse(apiRequestBodyAsJson);
+ final Long otpId = longOrNull(payload, "otpId");
+ final String otp = stringOrNull(payload, "otp");
+ return clientContactOtpService.verify(otpId, otp);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private JsonObject parse(final String body) {
+ if (body == null || body.isBlank()) {
+ return new JsonObject();
+ }
+ try {
+ return JsonParser.parseString(body).getAsJsonObject();
+ } catch (final Exception e) {
+ return new JsonObject();
+ }
+ }
+
+ private String stringOrNull(final JsonObject obj, final String key) {
+ return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
+ }
+
+ private Long longOrNull(final JsonObject obj, final String key) {
+ try {
+ return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+
+ private String clientIp(final HttpServletRequest req) {
+ if (req == null) {
+ return null;
+ }
+ final String fwd = req.getHeader("X-Forwarded-For");
+ if (fwd != null && !fwd.isBlank()) {
+ return fwd.split(",")[0].trim();
+ }
+ return req.getRemoteAddr();
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java
new file mode 100644
index 000000000..1523975d4
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java
@@ -0,0 +1,154 @@
+/**
+ * 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.contactverification.api;
+
+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.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
+import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
+import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.portfolio.client.api.ClientApiConstants;
+import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Sibling of {@link ClientContactVerificationApiResource}, for the CLIENT CREATION flow — a
+ * brand-new client has no id yet, so the {@code {clientId}} path segment used by the edit-time
+ * resource (which looks the client up via {@code findOneWithNotFoundDetection}) can't be reused
+ * as-is here. Same {@code ClientContactOtpService} underneath, same request/response shapes —
+ * only the path (no {@code clientId} segment, so no client-existence check) and the permission
+ * (CREATE instead of UPDATE) differ.
+ *
+ * + * Segment count disambiguates this from {@code ClientContactVerificationApiResource}'s path at the + * JAX-RS routing level (4 segments here vs. 5 with {@code {clientId}}), so both resources coexist + * without ambiguity. + * + *
+ * Deliberately does NOT contain the literal path segment {@code /self/} anywhere — see + * {@link ClientContactVerificationApiResource}'s class Javadoc for why that substring is unsafe in + * this codebase. + * + *
+ * POST /v1/clients/contact-verification/otp body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
+ * POST /v1/clients/contact-verification/otp/verify body: {"otpId":123,"otp":"123456"}
+ *
+ */
+@Path("/v1/clients/contact-verification/otp")
+@Component
+@Tag(name = "Client Creation Contact Verification",
+ description = "OTP verification of a brand-new client's mobile number / email address at creation time, "
+ + "required before POST /v1/clients will accept the value — guarantees every mobileNo/emailAddress "
+ + "ever stored was reachable at the moment it was recorded.")
+@RequiredArgsConstructor
+public class ClientCreationContactVerificationApiResource {
+
+ private final PlatformSecurityContext context;
+ private final ClientContactOtpService clientContactOtpService;
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Generate an OTP against a not-yet-created client's mobile number or email address",
+ description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated "
+ + "6-digit code to `destination` via SMTP. The returned otpId is required as mobileNewOtpId / "
+ + "emailNewOtpId on the subsequent POST /v1/clients call.")
+ public OtpGenerateResponse generate(final String apiRequestBodyAsJson, @Context final HttpServletRequest httpRequest) {
+
+ this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
+
+ final JsonObject payload = parse(apiRequestBodyAsJson);
+ final String channel = stringOrNull(payload, "channel");
+ final String destination = stringOrNull(payload, "destination");
+
+ final String ipAddress = clientIp(httpRequest);
+ final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
+
+ if ("EMAIL".equalsIgnoreCase(channel)) {
+ return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent);
+ }
+ if ("MOBILE".equalsIgnoreCase(channel)) {
+ return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent);
+ }
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid",
+ "channel must be one of MOBILE, EMAIL", channel);
+ }
+
+ @POST
+ @Path("verify")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Verify a previously generated client-creation contact OTP")
+ public OtpVerifyResponse verify(final String apiRequestBodyAsJson) {
+
+ this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
+
+ final JsonObject payload = parse(apiRequestBodyAsJson);
+ final Long otpId = longOrNull(payload, "otpId");
+ final String otp = stringOrNull(payload, "otp");
+ return clientContactOtpService.verify(otpId, otp);
+ }
+
+ // ── Helpers (mirrors ClientContactVerificationApiResource) ────────────────
+
+ private JsonObject parse(final String body) {
+ if (body == null || body.isBlank()) {
+ return new JsonObject();
+ }
+ try {
+ return JsonParser.parseString(body).getAsJsonObject();
+ } catch (final Exception e) {
+ return new JsonObject();
+ }
+ }
+
+ private String stringOrNull(final JsonObject obj, final String key) {
+ return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
+ }
+
+ private Long longOrNull(final JsonObject obj, final String key) {
+ try {
+ return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+
+ private String clientIp(final HttpServletRequest req) {
+ if (req == null) {
+ return null;
+ }
+ final String fwd = req.getHeader("X-Forwarded-For");
+ if (fwd != null && !fwd.isBlank()) {
+ return fwd.split(",")[0].trim();
+ }
+ return req.getRemoteAddr();
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java
new file mode 100644
index 000000000..5fd7af5aa
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java
@@ -0,0 +1,76 @@
+/**
+ * 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.contactverification.service;
+
+import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
+import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
+
+/**
+ * Client-facing OTP generate/verify used to gate mobileNo / emailAddress changes on
+ * {@code PUT /v1/clients/{clientId}} (see {@code ClientDataValidator} and
+ * {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}).
+ *
+ * + * This mirrors {@code LoginOtpService} / {@code EmailLoginOtpService} (same LincPay / local-hash + * mechanics, same {@code m_otp_log} bookkeeping) but takes a raw destination directly instead of + * resolving one from an {@code AppUser} — here the "user" being verified is a client's contact + * channel, entered by a staff member on the client's behalf, not a login user. + * + *
+ * Every row created by this service uses {@code purpose = "CLIENT_CONTACT_CHANGE"} so it is
+ * unambiguously distinct from the login-2FA rows in the same table.
+ */
+public interface ClientContactOtpService {
+
+ /**
+ * Send an OTP to {@code mobileNumber} via LincPay (same call shape as
+ * {@code LoginOtpServiceImpl.generate()}). {@code userId}/{@code username} are left null on the
+ * {@code m_otp_log} row since no AppUser is involved.
+ */
+ OtpGenerateResponse generateMobileOtp(String mobileNumber, String ipAddress, String userAgent);
+
+ /**
+ * Generate a 6-digit OTP locally, store its SHA-256 hash, and email it to {@code emailAddress}
+ * (same mechanics as {@code EmailLoginOtpServiceImpl.generate()}).
+ */
+ OtpGenerateResponse generateEmailOtp(String emailAddress, String ipAddress, String userAgent);
+
+ /**
+ * Verify an OTP issued by either of the above. Dispatches to the LincPay delegate or the local
+ * hash-compare depending on the referenced row's {@code provider}.
+ */
+ OtpVerifyResponse verify(Long otpId, String otp);
+
+ /**
+ * Assert that {@code otpId} references a {@code CLIENT_CONTACT_CHANGE}, {@code VERIFIED}
+ * {@code m_otp_log} row whose stored mobile number equals {@code expectedMobileNumber}. Throws
+ * {@link org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException} on
+ * any mismatch. Used by {@code ClientWritePlatformServiceJpaRepositoryImpl} immediately before
+ * applying a mobileNo change — never trusts the client-supplied JSON, always re-derives the
+ * expected destination from the DB (old) or the incoming command (new).
+ *
+ * @param otpIdParamLabel the request parameter name this otpId came from (e.g. {@code mobileOldOtpId},
+ * {@code mobileNewOtpId}, {@code emailOldOtpId}, {@code emailNewOtpId}) — used
+ * only to make the error message useful.
+ */
+ void assertVerifiedMobile(Long otpId, String expectedMobileNumber, String otpIdParamLabel);
+
+ /** Email counterpart of {@link #assertVerifiedMobile(Long, String, String)}. */
+ void assertVerifiedEmail(Long otpId, String expectedEmailAddress, String otpIdParamLabel);
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java
new file mode 100644
index 000000000..c5b06c825
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java
@@ -0,0 +1,462 @@
+/**
+ * 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.contactverification.service;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.HexFormat;
+import java.util.Locale;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
+import org.apache.fineract.infrastructure.email.service.EmailSenderService;
+import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
+import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
+import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
+import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
+import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
+import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.HttpServerErrorException;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * See {@link ClientContactOtpService}. Deliberately does NOT touch {@code LoginOtpServiceImpl} /
+ * {@code EmailLoginOtpServiceImpl} — this is a parallel, client-contact-change-specific variant that
+ * happens to talk to the same LincPay endpoints / SMTP path with a raw destination instead of an
+ * AppUser lookup. Small helper methods (JSON post/parse, sha256, six-digit generator) are duplicated
+ * rather than extracted, matching how this codebase already keeps each OTP-issuing service
+ * self-contained (only the genuinely cross-cutting {@link OtpDestinationMasker} was pulled out).
+ */
+@Service
+public class ClientContactOtpServiceImpl implements ClientContactOtpService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ClientContactOtpServiceImpl.class);
+
+ public static final String PURPOSE_CLIENT_CONTACT_CHANGE = "CLIENT_CONTACT_CHANGE";
+ private static final String PROVIDER_LINCPAY = "LINCPAY";
+ private static final String PROVIDER_EMAIL = "EMAIL";
+
+ private static final String STATUS_PENDING = "PENDING";
+ private static final String STATUS_SENT = "SENT";
+ private static final String STATUS_FAILED = "FAILED";
+ private static final String STATUS_VERIFIED = "VERIFIED";
+ private static final String STATUS_EXPIRED = "EXPIRED";
+
+ private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm");
+
+ @Value("${fineract.kyc.lincpay.api-key}")
+ private String apiKey;
+
+ @Value("${fineract.kyc.lincpay.secret}")
+ private String secret;
+
+ @Value("${fineract.kyc.lincpay.base-url}")
+ private String baseUrl;
+
+ @Value("${fineract.security.otp.lincpay.generate-path:/mobile/generate-otp}")
+ private String generateOtpPath;
+
+ @Value("${fineract.security.otp.lincpay.verify-path:/verify-mobile-otp}")
+ private String verifyOtpPath;
+
+ @Value("${fineract.security.otp.ttl-seconds:300}")
+ private int ttlSeconds;
+
+ @Value("${fineract.security.otp.max-attempts:5}")
+ private int maxAttempts;
+
+ @Value("${fineract.email.from-name:CBSnexora}")
+ private String appName;
+
+ private final RestTemplate restTemplate;
+ private final OtpLogRepository otpLogRepository;
+ private final EmailSenderService emailSenderService;
+ private final EmailTemplateService emailTemplateService;
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ @Autowired
+ public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
+ final EmailTemplateService emailTemplateService) {
+ this.restTemplate = new RestTemplate();
+ this.otpLogRepository = otpLogRepository;
+ this.emailSenderService = emailSenderService;
+ this.emailTemplateService = emailTemplateService;
+ }
+
+ // ── Generate ─────────────────────────────────────────────────────────────
+
+ @Override
+ @Transactional
+ public OtpGenerateResponse generateMobileOtp(final String mobileNumber, final String ipAddress, final String userAgent) {
+ if (StringUtils.isBlank(mobileNumber)) {
+ return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
+ }
+
+ final OtpLog log = new OtpLog();
+ log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
+ log.setProvider(PROVIDER_LINCPAY);
+ log.setMobileNumber(mobileNumber);
+ log.setStatus(STATUS_PENDING);
+ log.setIpAddress(ipAddress);
+ log.setUserAgent(userAgent);
+ log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
+
+ final JsonObject requestBody = new JsonObject();
+ requestBody.addProperty("apiKey", apiKey);
+ requestBody.addProperty("mobileNumber", mobileNumber);
+ requestBody.addProperty("secretKey", secret);
+
+ final JsonObject redactedRequest = new JsonObject();
+ redactedRequest.addProperty("apiKey", "***REDACTED***");
+ redactedRequest.addProperty("mobileNumber", mobileNumber);
+ redactedRequest.addProperty("secretKey", "***REDACTED***");
+ log.setRequestPayload(redactedRequest.toString());
+
+ try {
+ final String url = baseUrl + generateOtpPath;
+ final String rawResponse = post(url, requestBody.toString());
+ log.setResponsePayload(truncate(rawResponse, 65000));
+
+ final JsonObject response = parseJson(rawResponse);
+ if (isSuccess(response)) {
+ log.setStatus(STATUS_SENT);
+ log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number", "transactionId", "txnId",
+ "requestId"));
+ final OtpLog saved = otpLogRepository.saveAndFlush(log);
+ LOG.info("Client contact-change mobile OTP sent. otpId={}, mobile={}", saved.getId(),
+ OtpDestinationMasker.maskMobile(mobileNumber));
+ return OtpGenerateResponse.builder().success(true).otpId(saved.getId()).otpReference(saved.getOtpReference())
+ .maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber)).expiresInSeconds(ttlSeconds)
+ .message("OTP sent").build();
+ }
+
+ final String errorMsg = extractField(response, "message", "errorMessage", "error", "description");
+ log.setStatus(STATUS_FAILED);
+ log.setErrorMessage(errorMsg);
+ otpLogRepository.saveAndFlush(log);
+ LOG.warn("Client contact-change mobile OTP failed: {}", errorMsg);
+ return OtpGenerateResponse.builder().success(false).message(errorMsg != null ? errorMsg : "OTP send failed").build();
+
+ } catch (final HttpClientErrorException | HttpServerErrorException e) {
+ log.setStatus(STATUS_FAILED);
+ log.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
+ log.setResponsePayload(truncate(e.getResponseBodyAsString(), 65000));
+ otpLogRepository.saveAndFlush(log);
+ LOG.error("Client contact-change mobile OTP HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
+ return OtpGenerateResponse.builder().success(false).message("OTP send failed (HTTP " + e.getStatusCode() + ")").build();
+ } catch (final Exception e) {
+ log.setStatus(STATUS_FAILED);
+ log.setErrorMessage(truncate(e.getMessage(), 900));
+ otpLogRepository.saveAndFlush(log);
+ LOG.error("Client contact-change mobile OTP unexpected error", e);
+ return OtpGenerateResponse.builder().success(false).message("OTP send failed: " + e.getMessage()).build();
+ }
+ }
+
+ @Override
+ @Transactional
+ public OtpGenerateResponse generateEmailOtp(final String emailAddress, final String ipAddress, final String userAgent) {
+ if (StringUtils.isBlank(emailAddress)) {
+ return OtpGenerateResponse.builder().success(false).message("emailAddress is required").build();
+ }
+
+ final String otp = generateSixDigitOtp();
+ final int validityMinutes = Math.max(1, ttlSeconds / 60);
+
+ final OtpLog log = new OtpLog();
+ log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
+ log.setProvider(PROVIDER_EMAIL);
+ log.setEmailAddress(emailAddress);
+ log.setOtpHash(sha256(otp));
+ log.setStatus(STATUS_PENDING);
+ log.setIpAddress(ipAddress);
+ log.setUserAgent(userAgent);
+ log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
+
+ final String html = emailTemplateService.renderOtp(null, otp, validityMinutes);
+ final String subject = "Your OTP for Contact Verification - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP)
+ + ")";
+
+ final boolean sent = emailSenderService.sendHtml(emailAddress, subject, html);
+ if (sent) {
+ log.setStatus(STATUS_SENT);
+ final OtpLog saved = otpLogRepository.saveAndFlush(log);
+ LOG.info("Client contact-change email OTP sent. otpId={}, email={}", saved.getId(),
+ OtpDestinationMasker.maskEmail(emailAddress));
+ return OtpGenerateResponse.builder().success(true).otpId(saved.getId())
+ .maskedMobile(OtpDestinationMasker.maskEmail(emailAddress)).expiresInSeconds(ttlSeconds)
+ .message("OTP sent to the given email address").build();
+ }
+
+ log.setStatus(STATUS_FAILED);
+ log.setErrorMessage("SMTP send failed");
+ otpLogRepository.saveAndFlush(log);
+ return OtpGenerateResponse.builder().success(false).message("OTP send failed").build();
+ }
+
+ // ── Verify ───────────────────────────────────────────────────────────────
+
+ @Override
+ @Transactional
+ public OtpVerifyResponse verify(final Long otpId, final String otp) {
+ if (otpId == null || StringUtils.isBlank(otp)) {
+ return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build();
+ }
+
+ final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
+ if (row == null) {
+ return OtpVerifyResponse.builder().verified(false).message("OTP not found").build();
+ }
+ if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) {
+ return OtpVerifyResponse.builder().verified(false).message("OTP purpose mismatch").build();
+ }
+ if (STATUS_VERIFIED.equals(row.getStatus())) {
+ return OtpVerifyResponse.builder().verified(false).message("OTP already used").build();
+ }
+ if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) {
+ row.setStatus(STATUS_EXPIRED);
+ otpLogRepository.saveAndFlush(row);
+ return OtpVerifyResponse.builder().verified(false).message("OTP expired").build();
+ }
+ if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) {
+ row.setStatus(STATUS_EXPIRED);
+ otpLogRepository.saveAndFlush(row);
+ return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build();
+ }
+
+ row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1);
+
+ if (PROVIDER_LINCPAY.equals(row.getProvider())) {
+ return verifyViaLincPay(row, otp);
+ }
+ return verifyViaLocalHash(row, otp);
+ }
+
+ private OtpVerifyResponse verifyViaLincPay(final OtpLog row, final String otp) {
+ row.setOtpHash(sha256(otp));
+
+ final JsonObject requestBody = new JsonObject();
+ requestBody.addProperty("apiKey", apiKey);
+ requestBody.addProperty("mobileNumber", row.getMobileNumber());
+ requestBody.addProperty("otp", otp);
+ requestBody.addProperty("secretKey", secret);
+
+ try {
+ final String url = baseUrl + verifyOtpPath;
+ final String rawResponse = post(url, requestBody.toString());
+ final String existing = row.getResponsePayload() == null ? "" : row.getResponsePayload();
+ row.setResponsePayload(truncate(existing + "\n--verify--\n" + rawResponse, 65000));
+
+ final JsonObject response = parseJson(rawResponse);
+ if (isSuccess(response)) {
+ row.setStatus(STATUS_VERIFIED);
+ row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
+ otpLogRepository.saveAndFlush(row);
+ LOG.info("Client contact-change mobile OTP verified. otpId={}, mobile={}", row.getId(),
+ OtpDestinationMasker.maskMobile(row.getMobileNumber()));
+ return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
+ }
+
+ otpLogRepository.saveAndFlush(row);
+ final String providerMsg = extractField(response, "message", "errorMessage", "error", "description");
+ final int remaining = Math.max(0, maxAttempts - row.getAttempts());
+ return OtpVerifyResponse.builder().verified(false).message(providerMsg != null ? providerMsg : "Invalid OTP")
+ .attemptsRemaining(remaining).build();
+
+ } catch (final HttpClientErrorException | HttpServerErrorException e) {
+ row.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
+ otpLogRepository.saveAndFlush(row);
+ LOG.error("Client contact-change verify-mobile-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
+ return OtpVerifyResponse.builder().verified(false).message("OTP verification failed (HTTP " + e.getStatusCode() + ")")
+ .build();
+ } catch (final Exception e) {
+ row.setErrorMessage(truncate(e.getMessage(), 900));
+ otpLogRepository.saveAndFlush(row);
+ LOG.error("Client contact-change verify-mobile-otp unexpected error", e);
+ return OtpVerifyResponse.builder().verified(false).message("OTP verification failed: " + e.getMessage()).build();
+ }
+ }
+
+ private OtpVerifyResponse verifyViaLocalHash(final OtpLog row, final String otp) {
+ final boolean matched = row.getOtpHash() != null && row.getOtpHash().equals(sha256(otp));
+ if (matched) {
+ row.setStatus(STATUS_VERIFIED);
+ row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
+ otpLogRepository.saveAndFlush(row);
+ LOG.info("Client contact-change email OTP verified. otpId={}", row.getId());
+ return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
+ }
+
+ otpLogRepository.saveAndFlush(row);
+ final int remaining = Math.max(0, maxAttempts - row.getAttempts());
+ return OtpVerifyResponse.builder().verified(false).message("Invalid OTP").attemptsRemaining(remaining).build();
+ }
+
+ // ── Assertion helpers used by ClientWritePlatformServiceJpaRepositoryImpl ──
+
+ @Override
+ public void assertVerifiedMobile(final Long otpId, final String expectedMobileNumber, final String otpIdParamLabel) {
+ final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel);
+ if (!equalsIgnoringWhitespace(row.getMobileNumber(), expectedMobileNumber)) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch",
+ "The OTP referenced by '" + otpIdParamLabel + "' was not issued for this mobile number.", otpIdParamLabel);
+ }
+ }
+
+ @Override
+ public void assertVerifiedEmail(final Long otpId, final String expectedEmailAddress, final String otpIdParamLabel) {
+ final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel);
+ if (!equalsIgnoreCaseAndWhitespace(row.getEmailAddress(), expectedEmailAddress)) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch",
+ "The OTP referenced by '" + otpIdParamLabel + "' was not issued for this email address.", otpIdParamLabel);
+ }
+ }
+
+ private OtpLog requireVerifiedRow(final Long otpId, final String otpIdParamLabel) {
+ if (otpId == null) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.required",
+ "'" + otpIdParamLabel + "' is required to change this contact detail.", otpIdParamLabel);
+ }
+ final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
+ if (row == null) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.found",
+ "No OTP record found for '" + otpIdParamLabel + "' = " + otpId + ".", otpIdParamLabel, otpId);
+ }
+ if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.purpose.mismatch",
+ "'" + otpIdParamLabel + "' does not reference a contact-change OTP.", otpIdParamLabel);
+ }
+ if (!STATUS_VERIFIED.equals(row.getStatus())) {
+ throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.verified",
+ "'" + otpIdParamLabel + "' has not been verified yet. Complete the OTP verification step first.", otpIdParamLabel);
+ }
+ return row;
+ }
+
+ private boolean equalsIgnoringWhitespace(final String a, final String b) {
+ final String na = a == null ? null : a.trim();
+ final String nb = b == null ? null : b.trim();
+ return na != null && na.equals(nb);
+ }
+
+ private boolean equalsIgnoreCaseAndWhitespace(final String a, final String b) {
+ final String na = a == null ? null : a.trim().toLowerCase(Locale.ROOT);
+ final String nb = b == null ? null : b.trim().toLowerCase(Locale.ROOT);
+ return na != null && na.equals(nb);
+ }
+
+ // ── Helpers (deliberately duplicated from LoginOtpServiceImpl/EmailLoginOtpServiceImpl —
+ // see class Javadoc) ────────────────────────────────────────────────────
+
+ private String post(final String url, final String requestBodyJson) {
+ final HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("accept", "*/*");
+ final HttpEntity
+ * Email uses {@link EmailTemplateService#renderAlert(String, String, String)} (the existing generic
+ * branded "alert" template — not the OTP one) sent via {@link EmailSenderService}, the same
+ * lightweight direct-SMTP path (configured from this app's own {@code spring.mail.*} properties)
+ * already used by {@code EmailLoginOtpServiceImpl} for OTP emails. Deliberately does NOT use
+ * {@code PlatformEmailService} / {@code GmailBackedPlatformEmailService} — that path resolves SMTP
+ * credentials from {@code ExternalServicesPropertiesReadPlatformService} (a DB-configured "external
+ * services" table used by the campaign-email pipeline), which is a separate, likely-unconfigured
+ * configuration surface in this deployment. See class Javadoc discussion in the feature plan.
+ *
+ *
+ * SMS is a deliberate no-op-with-warning-log: LincPay's confirmed API surface in this codebase
+ * ({@code fineract.security.otp.lincpay.generate-path=/mobile/generate-otp} and
+ * {@code .../verify-path}) only exposes OTP generate/verify, not a plain "send this exact message"
+ * SMS endpoint. Routing a notification through the OTP-generate endpoint would create a real,
+ * verifiable OTP the client never asked to receive, which is worse than sending nothing. See
+ * {@link #sendSmsNotification(Client, String, String, String)}.
+ */
+@Service
+public class ClientChangeNotificationServiceImpl implements ClientChangeNotificationService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ClientChangeNotificationServiceImpl.class);
+
+ private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
+
+ @Value("${fineract.email.from-name:CBSnexora}")
+ private String appName;
+
+ private final EmailSenderService emailSenderService;
+ private final EmailTemplateService emailTemplateService;
+
+ @Autowired
+ public ClientChangeNotificationServiceImpl(final EmailSenderService emailSenderService,
+ final EmailTemplateService emailTemplateService) {
+ this.emailSenderService = emailSenderService;
+ this.emailTemplateService = emailTemplateService;
+ }
+
+ @Override
+ public void notifyContactOrAddressChanged(final Client client, final String fieldLabel, final String oldValue,
+ final String newValue, final String emailAddressBeforeUpdate) {
+ if (client == null) {
+ return;
+ }
+ sendEmailNotification(client, fieldLabel, oldValue, newValue, emailAddressBeforeUpdate);
+ sendSmsNotification(client, fieldLabel, oldValue, newValue);
+ }
+
+ private void sendEmailNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue,
+ final String emailAddressBeforeUpdate) {
+ final String recipientName = displayName(client);
+ final String alertTitle = fieldLabel + " Updated";
+ final String alertMessage = "Your " + fieldLabel.toLowerCase() + " on file was changed from "
+ + describeForDisplay(fieldLabel, oldValue) + " to " + describeForDisplay(fieldLabel, newValue) + " on "
+ + LocalDateTime.now().format(TIMESTAMP) + ". If you did not authorize this change, please contact us immediately.";
+ final String html = emailTemplateService.renderAlert(recipientName, alertTitle, alertMessage);
+ final String subject = alertTitle + " - " + appName;
+
+ if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) {
+ // When the email address itself is what changed, alert only the OLD address — the new
+ // address already proved ownership via the dual-OTP verification step at change time, so a
+ // duplicate confirmation there is unnecessary; the security-relevant alert is to the
+ // previous owner, in case the change wasn't authorized by them.
+ sendIfPresent(oldValue, subject, html);
+ } else {
+ // Mobile-number or address change: always alert the email the customer had BEFORE this
+ // update, never `client.getEmailAddress()` — if email was also changed in the very same
+ // request, the in-memory client already carries the NEW address by the time this runs, and
+ // sending "Mobile Number Updated" there would tell nobody but the (possibly unauthorized)
+ // new owner. The old address is the one the real customer is guaranteed to still read.
+ sendIfPresent(emailAddressBeforeUpdate, subject, html);
+ }
+ }
+
+ private void sendIfPresent(final String to, final String subject, final String html) {
+ if (StringUtils.isBlank(to)) {
+ LOG.warn("Change-notification email skipped — no recipient address available.");
+ return;
+ }
+ emailSenderService.sendHtml(to, subject, html);
+ }
+
+ /**
+ * SMS notification — see class Javadoc. Left as a clearly-logged no-op pending a confirmed
+ * LincPay plain-SMS endpoint; deliberately does NOT call the OTP-generate endpoint as a
+ * workaround.
+ */
+ private void sendSmsNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue) {
+ final String mobileNo = client.getMobileNo();
+ if (StringUtils.isBlank(mobileNo)) {
+ LOG.warn("Change-notification SMS skipped — client {} has no registered mobile number.", client.getId());
+ return;
+ }
+ LOG.warn(
+ "SMS change-notification NOT sent for client {} (field='{}') — no confirmed LincPay plain-SMS endpoint exists yet "
+ + "(only /mobile/generate-otp and /verify-mobile-otp are configured). Falling back to email-only "
+ + "notification. See ClientChangeNotificationServiceImpl Javadoc / TODO. mobile={}",
+ client.getId(), fieldLabel, OtpDestinationMasker.maskMobile(mobileNo));
+ }
+
+ private String describeForDisplay(final String fieldLabel, final String value) {
+ if (StringUtils.isBlank(value)) {
+ return "(not set)";
+ }
+ if (FIELD_LABEL_MOBILE_NO.equals(fieldLabel)) {
+ return OtpDestinationMasker.maskMobile(value);
+ }
+ if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) {
+ return OtpDestinationMasker.maskEmail(value);
+ }
+ return value;
+ }
+
+ private String displayName(final Client client) {
+ if (StringUtils.isNotBlank(client.getFirstname())) {
+ return client.getFirstname();
+ }
+ if (StringUtils.isNotBlank(client.getDisplayName())) {
+ return client.getDisplayName();
+ }
+ return "Customer";
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java
index 27f81563e..3f40f928c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java
@@ -63,6 +63,7 @@ import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
import org.apache.fineract.portfolio.account.service.AccountNumberGenerator;
import org.apache.fineract.portfolio.address.service.AddressWritePlatformService;
import org.apache.fineract.portfolio.client.api.ClientApiConstants;
+import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService;
import org.apache.fineract.portfolio.client.data.ClientDataValidator;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientEnumerations;
@@ -132,6 +133,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final CifNumberGenerator cifNumberGenerator;
+ private final ClientContactOtpService clientContactOtpService;
+ private final ClientChangeNotificationService clientChangeNotificationService;
@Transactional
@Override
@@ -416,6 +419,22 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
+
+ // A wrong/unreachable mobileNo or emailAddress entered at creation can never be fixed
+ // later — editing it requires a verified OTP against the OLD (wrong) value, which is
+ // impossible if that value was never real. Verifying here (single "new" leg — there's no
+ // "old" value for a brand-new client) guarantees every value ever stored was reachable at
+ // the moment it was recorded. See ClientDataValidator.validateForCreate() for the mandatory
+ // otpId presence check.
+ if (StringUtils.isNotBlank(mobileNo)) {
+ final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
+ this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, mobileNo, ClientApiConstants.mobileNewOtpIdParamName);
+ }
+ if (StringUtils.isNotBlank(emailAddress)) {
+ final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName);
+ this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, emailAddress, ClientApiConstants.emailNewOtpIdParamName);
+ }
+
// Title-case names at the boundary so every persistence path (REST, internal,
// bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the
// input. This is the single write seam for new clients — normalising here means we
@@ -724,6 +743,42 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
return element.toString().trim().length() < 4;
}
+ /**
+ * Gate a mobileNo/emailAddress change on dual OTP verification: both the OLD destination (the
+ * client's current DB value, re-derived here — never trusted from client-supplied JSON) and the
+ * NEW destination (the incoming command value) must each be backed by an already-VERIFIED
+ * {@code m_otp_log} row of purpose {@code CLIENT_CONTACT_CHANGE}. {@code ClientDataValidator}
+ * guarantees the relevant field-specific old/new otpId params are structurally present whenever
+ * mobileNo/emailAddress appears in the request; this is the semantic check that they actually
+ * prove ownership of the right destinations. Param pairs are field-specific
+ * ({@code mobileOldOtpId}/{@code mobileNewOtpId}, {@code emailOldOtpId}/{@code emailNewOtpId})
+ * rather than one generic pair, so mobileNo and emailAddress can both change in the same request
+ * without ambiguity.
+ *
+ *
+ * The OLD leg is skipped when the client doesn't currently have a value for this field (e.g. a
+ * client onboarded without a mobile number is having one set for the first time) — there is no
+ * existing verified channel to prove possession of in that case.
+ */
+ private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
+ final String newValue) {
+ if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
+ final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
+ final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
+ if (StringUtils.isNotBlank(oldValue)) {
+ this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
+ }
+ this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
+ } else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
+ final Long emailOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailOldOtpIdParamName);
+ final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName);
+ if (StringUtils.isNotBlank(oldValue)) {
+ this.clientContactOtpService.assertVerifiedEmail(emailOldOtpId, oldValue, ClientApiConstants.emailOldOtpIdParamName);
+ }
+ this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, newValue, ClientApiConstants.emailNewOtpIdParamName);
+ }
+ }
+
@Transactional
@Override
public CommandProcessingResult updateClient(final Long clientId, final JsonCommand command) {
@@ -761,10 +816,30 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
clientForUpdate.setExternalId(externalId);
}
+ // Dual-OTP contact-change gate — see ClientContactOtpService / ClientDataValidator.
+ // Track what actually changed here so we can fire the customer notification (SMS+email)
+ // AFTER the row is successfully saved, further down this method.
+ // Snapshotted BEFORE any field below is mutated — if mobile AND email are both changed in
+ // this same request, the mobile-change alert must still reach this original address, not
+ // whatever the client's email gets updated to a few lines down. See
+ // ClientChangeNotificationService#notifyContactOrAddressChanged Javadoc.
+ final String emailAddressBeforeUpdate = clientForUpdate.getEmailAddress();
+ String oldMobileNoForNotification = null;
+ String newMobileNoForNotification = null;
+ boolean mobileNoChanged = false;
+ String oldEmailAddressForNotification = null;
+ String newEmailAddressForNotification = null;
+ boolean emailAddressChanged = false;
+
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
+ final String oldValue = clientForUpdate.getMobileNo();
+ verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
changes.put(ClientApiConstants.mobileNoParamName, newValue);
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
+ oldMobileNoForNotification = oldValue;
+ newMobileNoForNotification = newValue;
+ mobileNoChanged = true;
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
@@ -775,8 +850,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
+ final String oldValue = clientForUpdate.getEmailAddress();
+ verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
changes.put(ClientApiConstants.emailAddressParamName, newValue);
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
+ oldEmailAddressForNotification = oldValue;
+ newEmailAddressForNotification = newValue;
+ emailAddressChanged = true;
}
// Title-case incoming names so an update with "ADITYA" or "aditya" still ends up
@@ -1184,6 +1264,20 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(clientForUpdate);
}
+ // Customer notification (SMS + email) — fired only after the row is durably saved, so we
+ // never notify about a change that ultimately failed to persist. Address changes are
+ // notified from AddressWritePlatformServiceImpl instead (no OTP gate there).
+ if (mobileNoChanged) {
+ this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate,
+ ClientChangeNotificationService.FIELD_LABEL_MOBILE_NO, oldMobileNoForNotification, newMobileNoForNotification,
+ emailAddressBeforeUpdate);
+ }
+ if (emailAddressChanged) {
+ this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate,
+ ClientChangeNotificationService.FIELD_LABEL_EMAIL_ADDRESS, oldEmailAddressForNotification,
+ newEmailAddressForNotification, emailAddressBeforeUpdate);
+ }
+
if (changes.containsKey(ClientApiConstants.legalFormIdParamName)) {
Integer legalFormValue = clientForUpdate.getLegalForm();
boolean isChangedToEntity = false;
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 b12a3bf79..6a3229653 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
@@ -440,6 +440,7 @@