feat: commit latest changes on dev
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class ClientFamilyMemberChildData implements Serializable {
|
||||
private final Long id;
|
||||
private final String fullName;
|
||||
private final Long genderId;
|
||||
private final String gender;
|
||||
private final LocalDate dateOfBirth;
|
||||
}
|
||||
+3
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
|
||||
private final LocalDate marriageDate;
|
||||
private final String marriageAuthority;
|
||||
|
||||
@lombok.Setter
|
||||
private java.util.List<ClientFamilyMemberChildData> children;
|
||||
|
||||
// template holder
|
||||
private final Collection<CodeValueData> relationshipIdOptions;
|
||||
private final Collection<CodeValueData> genderIdOptions;
|
||||
|
||||
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||
// Client edit approval email open-tracking pixel — hit by the client's email client
|
||||
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
||||
// path is the only capability credential; the endpoint always returns a GIF so
|
||||
// token validity is not leaked via different HTTP responses.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
|
||||
+84
-3
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/clients")
|
||||
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
|
||||
|
||||
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
|
||||
* whether the token matched, to avoid leaking token validity via response differences. */
|
||||
private static final byte[] TRANSPARENT_GIF = {
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
|
||||
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
|
||||
};
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a pending Client details edit",
|
||||
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
||||
+ "The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@Context final HttpServletRequest httpRequest) {
|
||||
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||
}
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
||||
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
||||
// reachable by the client's email client without needing a configured server.url property.
|
||||
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
|
||||
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
|
||||
: ":" + httpRequest.getServerPort());
|
||||
|
||||
// Tenant ID from the Fineract-specific header (same header the frontend sends).
|
||||
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
|
||||
tenantId != null ? tenantId : "default");
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
// ── Email preview (authenticated) ─────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
||||
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
||||
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
||||
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
||||
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
||||
if (preview == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("edit/pixel/{token}")
|
||||
@Produces("image/gif")
|
||||
@Operation(summary = "Email open tracking pixel",
|
||||
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
||||
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
||||
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
||||
public Response trackEmailOpen(@PathParam("token") final String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
try {
|
||||
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
||||
if (pendingEditOpt.isPresent()) {
|
||||
final var pendingEdit = pendingEditOpt.get();
|
||||
pendingEdit.markEmailOpened();
|
||||
pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
// Never fail a pixel request — email clients don't handle errors well.
|
||||
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
||||
}
|
||||
}
|
||||
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.header("Pragma", "no-cache")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||
nameVerifiedParamName, screeningLogIdParamName,
|
||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
||||
"oldMobileVerificationExceptionDocumentId"));
|
||||
|
||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||
|
||||
+5
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
|
||||
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
|
||||
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).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()
|
||||
|
||||
+14
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
|
||||
private final String checkerName;
|
||||
private final OffsetDateTime checkedDate;
|
||||
private final String rejectReason;
|
||||
|
||||
// ── Email tracking fields ─────────────────────────────────────────────────
|
||||
|
||||
/** Whether a confirmation email was successfully sent to the client after approval. */
|
||||
private final Boolean emailSent;
|
||||
|
||||
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
|
||||
private final OffsetDateTime emailSentAt;
|
||||
|
||||
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
|
||||
private final OffsetDateTime emailOpenedAt;
|
||||
|
||||
// emailSubject and emailBody are deliberately NOT included in the list DTO —
|
||||
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
@Entity
|
||||
@Table(name = "m_family_member_children")
|
||||
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "family_member_id", nullable = false)
|
||||
private ClientFamilyMembers familyMember;
|
||||
|
||||
@Column(name = "full_name", nullable = false)
|
||||
private String fullName;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gender_cv_id", nullable = false)
|
||||
private CodeValue gender;
|
||||
|
||||
@Column(name = "date_of_birth", nullable = true)
|
||||
private LocalDate dateOfBirth;
|
||||
|
||||
protected ClientFamilyMemberChildren() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
this.familyMember = familyMember;
|
||||
this.fullName = fullName;
|
||||
this.gender = gender;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
|
||||
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||
final LocalDate dateOfBirth) {
|
||||
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
|
||||
}
|
||||
|
||||
public ClientFamilyMembers getFamilyMember() {
|
||||
return this.familyMember;
|
||||
}
|
||||
|
||||
public void setFamilyMember(ClientFamilyMembers familyMember) {
|
||||
this.familyMember = familyMember;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return this.fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public CodeValue getGender() {
|
||||
return this.gender;
|
||||
}
|
||||
|
||||
public void setGender(CodeValue gender) {
|
||||
this.gender = gender;
|
||||
}
|
||||
|
||||
public LocalDate getDateOfBirth() {
|
||||
return this.dateOfBirth;
|
||||
}
|
||||
|
||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
}
|
||||
+18
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "modified_on")
|
||||
private LocalDateTime modifiedOn;
|
||||
|
||||
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
|
||||
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
|
||||
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
||||
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
|
||||
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
||||
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public Long getModifiedBy() { return modifiedBy; }
|
||||
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
||||
|
||||
public java.util.List<ClientFamilyMemberChildren> getChildren() {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
|
||||
if (this.children == null) {
|
||||
this.children = new java.util.ArrayList<>();
|
||||
} else {
|
||||
this.children.clear();
|
||||
}
|
||||
if (children != null) {
|
||||
this.children.addAll(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||
private String pendingLockKey;
|
||||
|
||||
// ── Email tracking ────────────────────────────────────────────────────────
|
||||
|
||||
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
|
||||
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
|
||||
@Column(name = "email_tracking_token", length = 36, unique = true)
|
||||
private String emailTrackingToken;
|
||||
|
||||
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
|
||||
@Column(name = "email_sent", nullable = false)
|
||||
private Boolean emailSent = Boolean.FALSE;
|
||||
|
||||
/** Timestamp of the successful send. Null when emailSent is false. */
|
||||
@Column(name = "email_sent_at")
|
||||
private OffsetDateTime emailSentAt;
|
||||
|
||||
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
|
||||
* the 1×1 img tag. Null until opened. */
|
||||
@Column(name = "email_opened_at")
|
||||
private OffsetDateTime emailOpenedAt;
|
||||
|
||||
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
|
||||
* without regenerating the template. */
|
||||
@Column(name = "email_subject", length = 300)
|
||||
private String emailSubject;
|
||||
|
||||
/** Exact HTML body that was sent — stored alongside email_subject. */
|
||||
@Column(name = "email_body", columnDefinition = "TEXT")
|
||||
private String emailBody;
|
||||
|
||||
/** Exact sender address configured at the time the email was dispatched. */
|
||||
@Column(name = "email_from", length = 255)
|
||||
private String emailFrom;
|
||||
|
||||
// ── Factory / lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||
final AppUser maker) {
|
||||
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
edit.makerId = maker.getId();
|
||||
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||
edit.emailSent = Boolean.FALSE;
|
||||
return edit;
|
||||
}
|
||||
|
||||
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
this.rejectReason = reason;
|
||||
this.pendingLockKey = null;
|
||||
}
|
||||
|
||||
/** Called after a successful email send. Records the token (for pixel tracking), the exact
|
||||
* subject and body, and marks the row as sent. */
|
||||
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
|
||||
this.emailTrackingToken = token;
|
||||
this.emailSubject = subject;
|
||||
this.emailBody = body;
|
||||
this.emailFrom = fromAddress;
|
||||
this.emailSent = Boolean.TRUE;
|
||||
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
|
||||
public void markEmailOpened() {
|
||||
if (this.emailOpenedAt == null) {
|
||||
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
|
||||
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||
|
||||
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||
|
||||
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
|
||||
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
|
||||
}
|
||||
|
||||
+6
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
|
||||
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||
}
|
||||
|
||||
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
|
||||
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
|
||||
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
|
||||
return this.repository.findByEmailTrackingToken(token);
|
||||
}
|
||||
|
||||
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||
return this.repository.saveAndFlush(edit);
|
||||
}
|
||||
|
||||
+3
-1
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
|
||||
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
||||
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
||||
|
||||
public static final String CHILDREN = "children";
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
||||
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
||||
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
|
||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
|
||||
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
||||
public static final String RELATION_SHIP_ID = "relationShipId";
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
|
||||
/**
|
||||
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
|
||||
* their pending profile-edit. Also records the tracking token, subject and body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
|
||||
* dialog can reproduce it later without regenerating the template.
|
||||
*
|
||||
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
|
||||
* that a mail-server outage never rolls back an already-committed approval.
|
||||
*/
|
||||
public interface ClientEditEmailService {
|
||||
|
||||
/**
|
||||
* Attempts to send a confirmation email to the client. On success, calls
|
||||
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
|
||||
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
|
||||
* {@code pendingEdit} — the approval itself is never rolled back.
|
||||
*
|
||||
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
|
||||
* yet persisted by the caller at this point)
|
||||
* @param client the client whose details were edited; used for display name and email
|
||||
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
|
||||
* {@code https://cbs.example.com}) used to build the tracking-pixel href
|
||||
* @param tenantId the current tenant identifier, used to build the pixel URL
|
||||
*/
|
||||
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
|
||||
* Checker. Records the tracking token, exact subject and exact body back onto the
|
||||
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
|
||||
* the entity after this method returns).
|
||||
*
|
||||
* <p>Template selection:
|
||||
* <ul>
|
||||
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
|
||||
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
|
||||
* {@code original_json}). Lists old→new values in a table.</li>
|
||||
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
|
||||
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
|
||||
* approval transaction to roll back. The caller catches any exception from this service and logs it.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
|
||||
|
||||
private static final String BANK_NAME = "CBSNexora";
|
||||
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
|
||||
|
||||
/** Contact fields that trigger Template A when they actually change. */
|
||||
private static final String FIELD_MOBILE = "mobileNo";
|
||||
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
|
||||
private static final String FIELD_EMAIL = "emailAddress";
|
||||
|
||||
/** Human-readable labels for every field that can appear in the email body — mirrors the
|
||||
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
|
||||
private static final Map<String, String> FIELD_LABELS;
|
||||
static {
|
||||
FIELD_LABELS = new LinkedHashMap<>();
|
||||
FIELD_LABELS.put("firstname", "First Name");
|
||||
FIELD_LABELS.put("middlename", "Middle Name");
|
||||
FIELD_LABELS.put("lastname", "Last Name");
|
||||
FIELD_LABELS.put("fullname", "Full Name");
|
||||
FIELD_LABELS.put("mobileNo", "Mobile Number");
|
||||
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
|
||||
FIELD_LABELS.put("emailAddress", "Email Address");
|
||||
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
|
||||
FIELD_LABELS.put("genderId", "Gender");
|
||||
FIELD_LABELS.put("maritalStatusId", "Marital Status");
|
||||
FIELD_LABELS.put("educationLevelId", "Education Level");
|
||||
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
|
||||
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
|
||||
FIELD_LABELS.put("occupationId", "Occupation");
|
||||
FIELD_LABELS.put("religionId", "Religion");
|
||||
FIELD_LABELS.put("socialCategoryId", "Social Category");
|
||||
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
|
||||
FIELD_LABELS.put("nationalityId", "Nationality");
|
||||
FIELD_LABELS.put("officeId", "Branch / Office");
|
||||
FIELD_LABELS.put("staffId", "Relationship Manager");
|
||||
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
|
||||
FIELD_LABELS.put("legalFormId", "Legal Form");
|
||||
FIELD_LABELS.put("salutationId", "Salutation");
|
||||
}
|
||||
|
||||
private final EmailSenderService emailSenderService;
|
||||
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
|
||||
|
||||
@Override
|
||||
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
|
||||
final String baseUrl, final String tenantId) {
|
||||
|
||||
final String toEmail = client.getEmailAddress();
|
||||
if (toEmail == null || toEmail.isBlank()) {
|
||||
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
|
||||
client.getId(), pendingEdit.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final String token = UUID.randomUUID().toString();
|
||||
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
|
||||
|
||||
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
|
||||
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
|
||||
|
||||
final boolean isContactChange = detectContactChange(payload, original);
|
||||
|
||||
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
|
||||
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
|
||||
|
||||
final String subject;
|
||||
final String body;
|
||||
if (isContactChange) {
|
||||
subject = "Your contact details have been updated — " + BANK_NAME;
|
||||
body = buildContactChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
} else {
|
||||
subject = "Your profile details have been updated — " + BANK_NAME;
|
||||
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
|
||||
checkedDateFormatted, pixelUrl);
|
||||
}
|
||||
|
||||
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
|
||||
|
||||
if (sent) {
|
||||
String fromEmail = "noreply@cbsnexora.in";
|
||||
try {
|
||||
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
|
||||
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
|
||||
fromEmail = smtp.getFromEmail();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
|
||||
}
|
||||
|
||||
pendingEdit.markEmailSent(token, subject, body, fromEmail);
|
||||
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
|
||||
} else {
|
||||
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
|
||||
}
|
||||
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
|
||||
pendingEdit.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Template detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if any of the three contact fields actually changed between
|
||||
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
|
||||
* falls back to false (Template B).
|
||||
*/
|
||||
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
|
||||
if (original == null) {
|
||||
return false;
|
||||
}
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String payloadVal = stringOf(payload, field);
|
||||
final String originalVal = stringOf(original, field);
|
||||
// Only counts as a change when both sides have a value and they differ, OR when
|
||||
// payload has a non-blank value and original didn't have the key at all.
|
||||
if (payloadVal != null && !payloadVal.equals(originalVal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Template A ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildContactChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder rows = new StringBuilder();
|
||||
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||
final String newVal = stringOf(payload, field);
|
||||
final String oldVal = original != null ? stringOf(original, field) : null;
|
||||
if (newVal == null) {
|
||||
continue;
|
||||
}
|
||||
if (oldVal != null && oldVal.equals(newVal)) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(field, field);
|
||||
rows.append("<tr>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
|
||||
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
|
||||
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
|
||||
.append(escHtml(newVal)).append("</td>")
|
||||
.append("</tr>");
|
||||
}
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
|
||||
+ "<thead><tr>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
|
||||
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
|
||||
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Template B ────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
|
||||
final JsonObject original, final String date, final String pixelUrl) {
|
||||
|
||||
final StringBuilder items = new StringBuilder();
|
||||
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
|
||||
final String key = entry.getKey();
|
||||
// Skip internal/non-display keys
|
||||
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
|
||||
continue;
|
||||
}
|
||||
final String payloadVal = stringOf(payload, key);
|
||||
final String originalVal = original != null ? stringOf(original, key) : null;
|
||||
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
|
||||
continue;
|
||||
}
|
||||
final String label = FIELD_LABELS.getOrDefault(key, key);
|
||||
items.append("<li>").append(escHtml(label)).append("</li>");
|
||||
}
|
||||
final String listHtml = items.length() > 0
|
||||
? "<ul style='padding-left:20px;'>" + items + "</ul>"
|
||||
: "<p><em>No specific fields identified.</em></p>";
|
||||
|
||||
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
|
||||
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||
+ "<p>The following fields were changed:</p>"
|
||||
+ listHtml
|
||||
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
|
||||
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
|
||||
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
|
||||
// without requiring a tenant header (it accepts the token and commits in the default
|
||||
// datasource context). However we embed it as a query param so future multi-tenant
|
||||
// support is easy to add without changing the stored URL.
|
||||
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
|
||||
}
|
||||
|
||||
private JsonObject parseJson(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final JsonElement el = JsonParser.parseString(json);
|
||||
return el.isJsonObject() ? el.getAsJsonObject() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOf(final JsonObject obj, final String key) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final JsonElement el = obj.get(key);
|
||||
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
||||
}
|
||||
|
||||
private String escHtml(final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
|
||||
|
||||
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult approveEdit(Long clientId);
|
||||
/**
|
||||
* Approves the pending edit and, on success, attempts to send a confirmation email to the
|
||||
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
|
||||
* can embed an absolute tracking-pixel URL in the email body.
|
||||
*
|
||||
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
|
||||
* @param tenantId current tenant identifier
|
||||
*/
|
||||
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
|
||||
|
||||
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||
}
|
||||
|
||||
+24
-1
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
|
||||
|
||||
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||
|
||||
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final ClientWritePlatformService clientWritePlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ClientEditEmailService clientEditEmailService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult approveEdit(final Long clientId) {
|
||||
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
|
||||
final AppUser checker = this.context.authenticatedUser();
|
||||
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||
|
||||
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
||||
// used, so every bit of Client's existing validation/change-detection logic runs exactly
|
||||
// as it would for an immediate edit — just deferred until a different user approves it.
|
||||
final Client client = pendingEdit.getClient();
|
||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
||||
pendingEdit.approve(checker);
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
// Send confirmation email — silently swallowed on any failure so the approval is never
|
||||
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
|
||||
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
|
||||
// email tracking columns. If the send fails or the client has no email, this save is
|
||||
// a no-op (email_sent remains false).
|
||||
try {
|
||||
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
|
||||
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
|
||||
} else {
|
||||
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
|
||||
pendingEdit.getId(), e);
|
||||
}
|
||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
||||
|
||||
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||
for (ClientFamilyMembersData fm : familyMembers) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return familyMembers;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
||||
|
||||
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||
if (fm != null) {
|
||||
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
|
||||
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
|
||||
"c.gender_cv_id as genderId, cv.code_value as gender " +
|
||||
"from m_family_member_children c " +
|
||||
"left join m_code_value cv on c.gender_cv_id = cv.id " +
|
||||
"where c.family_member_id = ?";
|
||||
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
|
||||
.id(rs.getLong("id"))
|
||||
.fullName(rs.getString("fullName"))
|
||||
.genderId(JdbcSupport.getLong(rs, "genderId"))
|
||||
.gender(rs.getString("gender"))
|
||||
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
|
||||
.build();
|
||||
}, familyMemberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+41
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
|
||||
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
|
||||
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||
if (childrenArray != null) {
|
||||
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
|
||||
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
|
||||
for (JsonElement childElement : childrenArray) {
|
||||
if (childElement.isJsonNull()) continue;
|
||||
JsonObject childObj = childElement.getAsJsonObject();
|
||||
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
|
||||
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
|
||||
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
|
||||
LocalDate dob = null;
|
||||
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
|
||||
try {
|
||||
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
|
||||
} catch (DateTimeParseException e) {
|
||||
LOG.warn("Could not parse dateOfBirth for child", e);
|
||||
}
|
||||
}
|
||||
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
|
||||
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
|
||||
}
|
||||
}
|
||||
}
|
||||
familyMember.setChildren(children);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateFamilyRequirementsForActivation(final Client client) {
|
||||
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
||||
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
||||
}
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
||||
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||
|
||||
if (member.has("children") && !member.get("children").isJsonNull()) {
|
||||
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
|
||||
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||
|
||||
}
|
||||
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||
clientFamilyMember.getRelationship(), familyMemberId);
|
||||
|
||||
if (command.parameterExists("children")) {
|
||||
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||
}
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||
|
||||
+9
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
|
||||
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
|
||||
*/
|
||||
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||
Integer limit);
|
||||
|
||||
/**
|
||||
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
|
||||
* edit — only populated once the approval email has been sent. Both {@code clientId} and
|
||||
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
|
||||
* when the record does not exist.
|
||||
*/
|
||||
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
|
||||
}
|
||||
|
||||
+37
-1
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
||||
pe.submitted_date AS submittedDate,
|
||||
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
|
||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
JOIN m_appuser maker ON maker.id = pe.maker_id
|
||||
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
||||
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
|
||||
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
|
||||
final boolean emailSent = rs.getBoolean("emailSent");
|
||||
return ClientPendingEditData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
.checkerName(rs.getString("checkerName")) //
|
||||
.checkedDate(checkedDate) //
|
||||
.rejectReason(rs.getString("rejectReason")) //
|
||||
.emailSent(emailSent) //
|
||||
.emailSentAt(emailSentAt) //
|
||||
.emailOpenedAt(emailOpenedAt) //
|
||||
.build();
|
||||
};
|
||||
|
||||
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
||||
|
||||
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
|
||||
final String sql = """
|
||||
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
|
||||
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
|
||||
c.email_address AS clientEmail
|
||||
FROM m_client_pending_edit pe
|
||||
JOIN m_client c ON c.id = pe.client_id
|
||||
WHERE pe.id = ? AND pe.client_id = ?
|
||||
""";
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
||||
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("emailSubject", rs.getString("emailSubject"));
|
||||
result.put("emailBody", rs.getString("emailBody"));
|
||||
result.put("emailSent", rs.getBoolean("emailSent"));
|
||||
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
|
||||
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
|
||||
result.put("emailFrom", rs.getString("emailFrom"));
|
||||
result.put("emailTo", rs.getString("clientEmail"));
|
||||
return result;
|
||||
}, pendingEditId, clientId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -136,6 +137,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
private final CifNumberGenerator cifNumberGenerator;
|
||||
private final ClientContactOtpService clientContactOtpService;
|
||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -788,13 +790,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
* 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,
|
||||
private void verifyDualOtpForContactChange(final Long clientId, 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);
|
||||
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
|
||||
|
||||
if (StringUtils.isNotBlank(oldValue)) {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
|
||||
final Integer docCount = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
|
||||
Integer.class, oldMobileExceptionDocId, clientId);
|
||||
if (docCount == null || docCount == 0) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.client.mobile.old.verification.exception.invalid.document",
|
||||
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
|
||||
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
|
||||
}
|
||||
} else {
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||
}
|
||||
}
|
||||
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
||||
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
||||
@@ -862,7 +878,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
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);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
||||
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldMobileNoForNotification = oldValue;
|
||||
@@ -879,7 +895,7 @@ 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);
|
||||
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||
oldEmailAddressForNotification = oldValue;
|
||||
|
||||
@@ -497,4 +497,7 @@
|
||||
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0444_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0445_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0446_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+13
-13
@@ -42,19 +42,19 @@
|
||||
VALUES
|
||||
('CTR_THRESHOLD_10L', 'Mandatory CTR reporting threshold - single cash transaction of INR 10,00,000 or more', 'ALL', 1000000, 'THRESHOLD', null, null, true),
|
||||
('STRUCTURED_CTR_10L_MONTHLY', 'Connected cash transactions summing to INR 10,00,000 or more within a calendar month (CTR aggregation rule)', 'ALL', 1000000, 'STRUCTURING', 30, null, true),
|
||||
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6), true),
|
||||
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28), true),
|
||||
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29), true),
|
||||
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30), true),
|
||||
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31), true),
|
||||
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65), true),
|
||||
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12), true),
|
||||
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13), true),
|
||||
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10), true),
|
||||
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11), true),
|
||||
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14), true),
|
||||
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24), true),
|
||||
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73), true);
|
||||
('LARGE_CASH_TXN_25L', 'Large cash transaction', 'ALL', 2500000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 6 LIMIT 1), true),
|
||||
('STRUCTURING_BELOW_10L', 'Splitting of cash deposits just below INR 10,00,000 in multiple accounts in a month', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 28 LIMIT 1), true),
|
||||
('STRUCTURING_BELOW_50K', 'Splitting of cash deposits just below INR 50,000', 'SAVINGS', 50000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 29 LIMIT 1), true),
|
||||
('FREQUENT_LOW_CASH_DEPOSIT', 'Frequent low cash deposits', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 30 LIMIT 1), true),
|
||||
('FREQUENT_LOW_CASH_WITHDRAWAL', 'Frequent low cash withdrawals', 'SAVINGS', 50000, 'VELOCITY', 7, (SELECT id FROM m_client_risk_rule WHERE order_position = 31 LIMIT 1), true),
|
||||
('FREQUENT_CASH_NEAR_10L', 'Frequent cash deposits just below INR 10,00,000', 'SAVINGS', 1000000, 'STRUCTURING', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 65 LIMIT 1), true),
|
||||
('RTGS_HIGH_VALUE', 'Monitoring RTGS Transactions', 'TRANSFER', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 12 LIMIT 1), true),
|
||||
('JEWELLER_ACCOUNT_LARGE_TXN', 'Monitoring accounts of Jewellers', 'ALL', 2000000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 13 LIMIT 1), true),
|
||||
('CHARITABLE_ORG_CASH_WITHDRAWAL', 'Substantial cash withdrawals in accounts of charitable organization', 'SAVINGS', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 10 LIMIT 1), true),
|
||||
('PEP_ACCOUNT_MONITORING', 'Monitoring accounts of PEP (politically exposed person)', 'ALL', 50000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 11 LIMIT 1), true),
|
||||
('NEW_ACCOUNT_LARGE_TXN', 'New account Large transaction', 'ALL', 500000, 'THRESHOLD', 30, (SELECT id FROM m_client_risk_rule WHERE order_position = 14 LIMIT 1), true),
|
||||
('RAPID_MOVEMENT_IN_ACCOUNTS', 'Rapid Movement in accounts (same-day credit followed by debit)', 'SAVINGS', null, 'VELOCITY', 1, (SELECT id FROM m_client_risk_rule WHERE order_position = 24 LIMIT 1), true),
|
||||
('LOAN_REPAYMENT_IN_CASH', 'Repayment of loan in cash', 'LOAN', 200000, 'THRESHOLD', null, (SELECT id FROM m_client_risk_rule WHERE order_position = 73 LIMIT 1), true);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!-- Email confirmation tracking for the Client edit approval workflow.
|
||||
When a Clerk approves a pending client edit, a confirmation email is sent to the
|
||||
client's registered email address. These six columns record the outcome of that send
|
||||
and allow staff to see whether the client has opened the email (via a 1x1 tracking
|
||||
pixel whose URL embeds the UUID email_tracking_token).
|
||||
|
||||
email_subject / email_body store the exact content that was sent so a "View Email"
|
||||
dialog can show it later without regenerating the template.
|
||||
|
||||
email_tracking_token is a random UUID generated at send time — looked up directly by
|
||||
the public pixel endpoint (GET /api/*/clients/edit/pixel/{token}) so the endpoint
|
||||
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
|
||||
|
||||
<changeSet author="CBS" id="0444-1-add-email-tracking-token">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_tracking_token" type="VARCHAR(36)">
|
||||
<constraints nullable="true" unique="true"
|
||||
uniqueConstraintName="uq_client_pending_edit_email_token"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-2-add-email-sent">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-3-add-email-sent-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-4-add-email-opened-at">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-5-add-email-subject">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_subject" type="VARCHAR(300)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0444-6-add-email-body">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_body" type="TEXT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_from" type="VARCHAR(255)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet id="1" author="fineract">
|
||||
<createTable tableName="m_family_member_children">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="family_member_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="full_name" type="VARCHAR(255)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="gender_cv_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="date_of_birth" type="DATE">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="family_member_id" constraintName="fk_family_member_children_family_member_id" referencedTableName="m_family_members" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_family_member_children" baseColumnNames="gender_cv_id" constraintName="fk_family_member_children_gender_cv_id" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+1
@@ -116,6 +116,7 @@
|
||||
<class>org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat</class>
|
||||
|
||||
<!-- Client Module Entities -->
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientFamilyMembers</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientNonPerson</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientTransferDetails</class>
|
||||
|
||||
+1593
File diff suppressed because it is too large
Load Diff
+2777
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user