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

This commit is contained in:
Anshul Rathore
2026-08-06 10:46:22 +05:30
parent 84f8aa2274
commit b9aa29fb35
30 changed files with 5354 additions and 27 deletions
@@ -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;
}
@@ -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;
@@ -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.
@@ -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();
}
}
@@ -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,
@@ -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()
@@ -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.
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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();
}
}
}
@@ -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);
}
@@ -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);
}
@@ -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;
@@ -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);
}
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -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);
}
@@ -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;
}
@@ -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
@@ -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();
@@ -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);
}
@@ -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;
}
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2777
View File
File diff suppressed because it is too large Load Diff