From 14df914b9acfc48a66bec719c2b806811a754e79 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 26 Jul 2026 14:36:48 +0530 Subject: [PATCH] feat(clients): dual-OTP verified contact changes with customer notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile number and email address changes now require OTP verification on both the old and new destination (staff-entered) before the update is accepted, at both client creation and edit time — closing a gap where an unverified wrong number/email could never be corrected later. Every mobile/email/address change also emails the customer at their pre-change address so an unauthorized change is never silent. - New ClientContactOtpService (+Impl) mirrors the existing login OTP services, reusing OtpLog with a CLIENT_CONTACT_CHANGE purpose. - New sibling API resources for edit (/clients/{id}/contact-verification/otp) and create (/clients/contact-verification/otp, no clientId yet). - ClientWritePlatformServiceJpaRepositoryImpl / AddressWritePlatformServiceImpl gate the actual field change on verified OTPs and fire ClientChangeNotificationService afterwards; the notification recipient is always snapshotted before any mutation so a same-request mobile+email change can't accidentally alert the new (unverified-by-its-real-owner) address instead of the old one. - ClientDataValidator validates OTP id format only (ignoreIfNull) — enforcement lives solely in the write-service's diff-gated calls, since edit-client always PUTs the full client object regardless of what changed. - 0420 migration seeds 8 CLIENTKYC action permissions (GenerateAadhaarOtp/VerifyAadhaar/VerifyPan/VerifyCin/VerifyDin/ VerifyGstin/UploadCkyc/CompleteRekyc) that were referenced by existing command handlers but never seeded, causing a 404 for every user including Super User. Co-Authored-By: Claude Sonnet 5 --- .../client/api/ClientApiConstants.java | 9 + .../AddressWritePlatformServiceImpl.java | 38 ++ .../ClientContactVerificationApiResource.java | 159 ++++++ ...reationContactVerificationApiResource.java | 154 ++++++ .../service/ClientContactOtpService.java | 76 +++ .../service/ClientContactOtpServiceImpl.java | 462 ++++++++++++++++++ .../data/ClientApiCollectionConstants.java | 6 +- .../client/data/ClientDataValidator.java | 54 ++ .../ClientChangeNotificationService.java | 57 +++ .../ClientChangeNotificationServiceImpl.java | 159 ++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 94 ++++ .../db/changelog/tenant/changelog-tenant.xml | 1 + ...d_missing_clientkyc_action_permissions.xml | 106 ++++ 13 files changed, 1373 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientContactVerificationApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/api/ClientCreationContactVerificationApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/contactverification/service/ClientContactOtpServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0420_add_missing_clientkyc_action_permissions.xml 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 entity = new HttpEntity<>(requestBodyJson, headers); + final ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + return response.getBody(); + } + + private JsonObject parseJson(final String raw) { + if (raw == null || raw.isBlank()) { + return new JsonObject(); + } + final JsonElement parsed = JsonParser.parseString(raw); + return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + } + + private boolean isSuccess(final JsonObject response) { + for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) { + if (response.has(key)) { + final JsonElement v = response.get(key); + if (v.isJsonPrimitive()) { + final String s = v.getAsString(); + if ("200".equals(s) || "0".equals(s) || "true".equalsIgnoreCase(s) || "success".equalsIgnoreCase(s) + || "SUCCESS".equalsIgnoreCase(s)) { + return true; + } + } + } + } + if (response.has("success") && response.get("success").isJsonPrimitive()) { + return response.get("success").getAsBoolean(); + } + return false; + } + + private String extractField(final JsonObject obj, final String... keys) { + if (obj == null) { + return null; + } + for (final String key : keys) { + if (obj.has(key) && obj.get(key).isJsonPrimitive()) { + return obj.get(key).getAsString(); + } + } + if (obj.has("data") && obj.get("data").isJsonObject()) { + final JsonObject data = obj.getAsJsonObject("data"); + for (final String key : keys) { + if (data.has(key) && data.get(key).isJsonPrimitive()) { + return data.get(key).getAsString(); + } + } + } + return null; + } + + private String generateSixDigitOtp() { + final int n = secureRandom.nextInt(1_000_000); + return String.format("%06d", n); + } + + private String sha256(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private String truncate(final String s, final int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java index 063cfc30c..c68bd0996 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java @@ -39,7 +39,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants { dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, nameVerifiedParamName, screeningLogIdParamName, - clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName)); + clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName, + mobileNewOtpIdParamName, emailNewOtpIdParamName)); protected static final Set CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, @@ -61,7 +62,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants { dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, nameVerifiedParamName, screeningLogIdParamName, - submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName)); + submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName, + mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName)); protected static final Set CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java index 6fa5e76e8..ddd3a9452 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientDataValidator.java @@ -166,6 +166,24 @@ public final class ClientDataValidator { .notExceedingLengthOf(50); } + // A mobile number/email address entered at client creation is never re-verified before this + // point — if it's a typo (wrong number, doesn't belong to this customer), the client would be + // permanently stuck later: editing it requires proving ownership of the OLD (wrong) value via + // OTP, which is impossible if that value was never real. Requiring a verified OTP for the + // value here — the same ClientContactOtpService used by the edit-time dual-OTP gate, just the + // single "new" leg since there's no "old" value yet — guarantees every mobileNo/emailAddress + // ever stored was actually reachable at the moment it was recorded. + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNoParamName, element)) { + final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).notNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailAddressParamName, element)) { + final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).notNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.alternateMobileNoParamName, element)) { final String alternateMobileNo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.alternateMobileNoParamName, element); baseDataValidator.reset().parameter(ClientApiConstants.alternateMobileNoParamName).value(alternateMobileNo).ignoreIfNull() @@ -495,6 +513,42 @@ public final class ClientDataValidator { .notExceedingLengthOf(50); } + // Dual-OTP contact-change gate: a mobileNo or emailAddress change must reference an + // already-VERIFIED m_otp_log row for the OLD destination and one for the NEW destination + // (see ClientContactOtpService + ClientWritePlatformServiceJpaRepositoryImpl.updateClient()). + // + // IMPORTANT: this validator only sees the raw request JSON, not the client's current DB row — + // it cannot tell "emailAddress is present but unchanged" (this codebase's edit-client screen + // always PUTs the full client object, changed fields or not) apart from "emailAddress is + // present and being changed". Asserting these params mandatory here would reject ordinary + // saves that don't touch contact details at all. The real gate — requiring a verified otpId + // ONLY when the value actually differs from the DB — already lives correctly in + // ClientWritePlatformServiceJpaRepositoryImpl.updateClient() (inside the + // isChangeInStringParameterNamed(...) blocks) via ClientContactOtpService.assertVerifiedMobile/ + // assertVerifiedEmail, which itself throws if the otpId is null. So here we only validate + // shape (a positive long) when the ids happen to be present, and leave presence-enforcement to + // the write service, which is the only place that knows whether this is a real change. + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileOldOtpIdParamName, element)) { + final Long mobileOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileOldOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) { + final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailOldOtpIdParamName, element)) { + final Long emailOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailOldOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailOldOtpIdParamName).value(emailOldOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailNewOtpIdParamName, element)) { + final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element); + baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).ignoreIfNull() + .longGreaterThanZero(); + } + final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ClientApiConstants.activeParamName, element); if (active != null) { atLeastOneParameterPassedForUpdate = true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java new file mode 100644 index 000000000..03f313dae --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationService.java @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import org.apache.fineract.portfolio.client.domain.Client; + +/** + * Notifies a client (SMS + email) whenever their mobileNo, emailAddress, or address changes. Called + * from {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()} (after a successful + * mobileNo/emailAddress save) and from {@code AddressWritePlatformServiceImpl.updateClientAddress()} + * (after a successful address save, notification-only — address changes are not gated by OTP). + */ +public interface ClientChangeNotificationService { + + String FIELD_LABEL_MOBILE_NO = "Mobile Number"; + String FIELD_LABEL_EMAIL_ADDRESS = "Email Address"; + String FIELD_LABEL_ADDRESS = "Address"; + + /** + * @param client the client whose contact detail changed (already saved with the NEW value at + * the time this is called) + * @param fieldLabel one of {@link #FIELD_LABEL_MOBILE_NO}, {@link #FIELD_LABEL_EMAIL_ADDRESS}, or + * {@link #FIELD_LABEL_ADDRESS} — use these exact constants so the implementation's + * "notify both old and new email owner" special case is triggered correctly. + * @param oldValue the value being replaced (raw, unmasked — the implementation masks contact + * destinations for display and uses the raw value to decide where to send) + * @param newValue the value it was changed to (raw, unmasked) + * @param emailAddressBeforeUpdate the client's email address as it was BEFORE this update + * transaction touched anything — used (for the mobile/address case) as the + * notification recipient instead of {@code client.getEmailAddress()}. This + * matters when mobile and email are both changed in the same request: by the + * time this is called the in-memory client already carries the NEW email, so + * reading it directly would send the "Mobile Number Updated" alert to the new + * (unverified-by-the-real-owner-until-now) address instead of the address the + * customer actually still controls. Ignored for the + * {@link #FIELD_LABEL_EMAIL_ADDRESS} case, which already always notifies + * {@code oldValue} directly. + */ + void notifyContactOrAddressChanged(Client client, String fieldLabel, String oldValue, String newValue, + String emailAddressBeforeUpdate); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.java new file mode 100644 index 000000000..baa4dc4a4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientChangeNotificationServiceImpl.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.service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.email.service.EmailSenderService; +import org.apache.fineract.infrastructure.email.service.EmailTemplateService; +import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker; +import org.apache.fineract.portfolio.client.domain.Client; +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.stereotype.Service; + +/** + * See {@link ClientChangeNotificationService}. + * + *

+ * 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 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'GENERATEAADHAROTP_CLIENTKYC' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +