Compare commits

...
6 Commits
Author SHA1 Message Date
Anshul Rathore b9aa29fb35 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
2026-08-06 10:46:22 +05:30
Anshul Rathore 84f8aa2274 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-08-03 12:51:55 +05:30
Anshul Rathore 81832b6bb7 feat: add Avro schema definitions for Fineract data entities and update configuration
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 - 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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
2026-07-31 13:27:40 +05:30
Anshul Rathore 0bd5296519 chore: update JVM memory configuration and add local API integration scripts
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 - MySQL / 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 (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 - 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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
2026-07-31 12:41:35 +05:30
Anshul Rathore 17e0dab435 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-07-31 12:41:22 +05:30
Anshul Rathore 67810bfaea Add Welcome Kit Dispatch feature and Share Account summary API 2026-07-31 11:51:57 +05:30
54 changed files with 13074 additions and 30 deletions
+1
View File
@@ -0,0 +1 @@
{"username":"mifos", "password":"password"}
+18
View File
@@ -0,0 +1,18 @@
const https = require('https');
const agent = new https.Agent({ rejectUnauthorized: false });
fetch('https://localhost:8443/fineract-provider/api/v1/authentication?username=mifos&password=password&tenantIdentifier=default', {
method: 'POST',
agent: agent
}).then(res => res.json()).then(data => {
const token = data.base64EncodedAuthenticationKey;
return fetch('https://localhost:8443/fineract-provider/api/v1/clients/9/accounts?tenantIdentifier=default', {
headers: {
'Authorization': 'Basic ' + token,
'Fineract-Platform-TenantId': 'default'
},
agent: agent
});
}).then(res => res.json()).then(data => {
console.log(JSON.stringify(data, null, 2));
}).catch(console.error);
@@ -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;
@@ -0,0 +1,46 @@
package org.apache.fineract.portfolio.shareaccounts.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/shareaccounts/summary")
@Component
@Tag(name = "Share Account Summary", description = "Provides a highly optimized summary of all share accounts linked with their respective clients.")
@RequiredArgsConstructor
public class ShareAccountSummaryApiResource {
private final PlatformSecurityContext platformSecurityContext;
private final ShareAccountReadPlatformService shareAccountReadPlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all share accounts with client details", description = "Retrieves all share accounts along with their associated client details in a single optimized query.\n\n"
+ "Example Requests:\n\n" + "shareaccounts/summary")
@ApiResponse(responseCode = "200", description = "OK")
public Collection<ShareAccountClientSummaryData> retrieveAllSummaries(@QueryParam("searchTerm") final String searchTerm) {
long start = System.currentTimeMillis();
System.out.println("[TIMING] Start of retrieveAllSummaries (before auth)");
this.platformSecurityContext.authenticatedUser();
long afterAuth = System.currentTimeMillis();
System.out.println("[TIMING] Auth check: " + (afterAuth - start) + "ms");
Collection<ShareAccountClientSummaryData> result = this.shareAccountReadPlatformService.retrieveAllShareAccountClientSummaries(searchTerm);
long afterService = System.currentTimeMillis();
System.out.println("[TIMING] Service call total (including query): " + (afterService - afterAuth) + "ms");
System.out.println("[TIMING] Right before response returned: " + (System.currentTimeMillis() - start) + "ms total so far");
return result;
}
}
@@ -0,0 +1,54 @@
package org.apache.fineract.portfolio.shareaccounts.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData;
@Getter
public class ShareAccountClientSummaryData implements Serializable {
private final Long id;
private final Long clientId;
private final String clientName;
private final Long officeId;
private final String officeName;
private final String accountNo;
private final Long productId;
private final String productName;
private final Long totalApprovedShares;
private final Long totalPendingForApprovalShares;
private final CurrencyData currency;
private final LocalDate activatedDate;
private final ShareAccountStatusEnumData status;
public ShareAccountClientSummaryData(final Long id, final Long clientId, final String clientName, final Long officeId, final String officeName, final String accountNo,
final Long productId, final String productName, final Long totalApprovedShares, final Long totalPendingForApprovalShares,
final CurrencyData currency, final LocalDate activatedDate, final ShareAccountStatusEnumData status) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.officeId = officeId;
this.officeName = officeName;
this.accountNo = accountNo;
this.productId = productId;
this.productName = productName;
if (totalApprovedShares == null) {
this.totalApprovedShares = Long.valueOf(0);
} else {
this.totalApprovedShares = totalApprovedShares;
}
if (totalPendingForApprovalShares == null) {
this.totalPendingForApprovalShares = Long.valueOf(0);
} else {
this.totalPendingForApprovalShares = totalPendingForApprovalShares;
}
this.currency = currency;
this.activatedDate = activatedDate;
this.status = status;
}
}
@@ -23,6 +23,7 @@ import java.util.Collection;
import java.util.Set;
import org.apache.fineract.portfolio.accounts.service.AccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
public interface ShareAccountReadPlatformService extends AccountReadPlatformService {
@@ -38,5 +39,7 @@ public interface ShareAccountReadPlatformService extends AccountReadPlatformServ
Collection<ShareAccountData> retrieveAllShareAccountDataForDividends(Long productId, boolean fetchInActiveAccounts,
LocalDate startDate);
Collection<ShareAccountClientSummaryData> retrieveAllShareAccountClientSummaries(String searchTerm);
String retrieveAccountNumberByAccountId(Long accountId);
}
@@ -62,6 +62,7 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountStatusType
import org.apache.fineract.portfolio.shareproducts.data.ShareProductData;
import org.apache.fineract.portfolio.shareproducts.data.ShareProductMarketPriceData;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductDropdownReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
import org.springframework.context.ApplicationContext;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -221,6 +222,29 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
}, mapper);
}
@Override
public Collection<ShareAccountClientSummaryData> retrieveAllShareAccountClientSummaries(String searchTerm) {
long start = System.currentTimeMillis();
System.out.println("[TIMING] Inside retrieveAllShareAccountClientSummaries, before query builder setup");
ShareAccountClientSummaryMapper mapper = new ShareAccountClientSummaryMapper();
StringBuilder queryBuilder = new StringBuilder("select ").append(mapper.schema());
List<Object> paramList = new ArrayList<>();
if (searchTerm != null && !searchTerm.trim().isEmpty()) {
queryBuilder.append(" where (c.display_name ILIKE ? OR sa.account_no ILIKE ?)");
String searchPattern = "%" + searchTerm + "%";
paramList.add(searchPattern);
paramList.add(searchPattern);
}
long beforeQuery = System.currentTimeMillis();
System.out.println("[TIMING] Before actual SQL query call. Time since service start: " + (beforeQuery - start) + "ms");
Collection<ShareAccountClientSummaryData> result = this.jdbcTemplate.query(queryBuilder.toString(), mapper, paramList.toArray());
long afterQuery = System.currentTimeMillis();
System.out.println("[TIMING] Query execution (JdbcTemplate): " + (afterQuery - beforeQuery) + "ms");
return result;
}
public Collection<ShareAccountChargeData> convertChargesToShareAccountCharges(Collection<ChargeData> productCharges) {
final Collection<ShareAccountChargeData> savingsCharges = new ArrayList<>();
for (final ChargeData chargeData : productCharges) {
@@ -495,6 +519,63 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
}
}
private static final class ShareAccountClientSummaryMapper implements RowMapper<ShareAccountClientSummaryData> {
private final String schema;
ShareAccountClientSummaryMapper() {
StringBuilder buff = new StringBuilder().append("sa.id as id, c.id as clientId, c.display_name as clientName, ")
.append("sa.account_no as accountNo, p.id as productId, p.name as productName, ")
.append("sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ")
.append("sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, ")
.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append("curr.display_symbol as currencyDisplaySymbol, sa.activated_date as activatedDate, ")
.append("sa.status_enum as statusEnum, o.id as officeId, o.name as officeName ")
.append("from m_share_account sa ")
.append("inner join m_client c ON c.id = sa.client_id ")
.append("inner join m_office o ON o.id = c.office_id ")
.append("inner join m_share_product p on p.id = sa.product_id ")
.append("inner join m_currency curr on curr.code = sa.currency_code");
this.schema = buff.toString();
}
@Override
public ShareAccountClientSummaryData mapRow(ResultSet rs, @SuppressWarnings("unused") int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String accountNo = rs.getString("accountNo");
final Long productId = rs.getLong("productId");
final String productName = rs.getString("productName");
final Long totalApprovedShares = JdbcSupport.getLong(rs, "approvedShares");
final Long totalPendingForApprovalShares = JdbcSupport.getLong(rs, "pendingShares");
final String currencyCode = rs.getString("currencyCode");
final String currencyName = rs.getString("currencyName");
final String currencyNameCode = rs.getString("currencyNameCode");
final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol");
final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits");
final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf");
final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf,
currencyDisplaySymbol, currencyNameCode);
final LocalDate activatedDate = JdbcSupport.getLocalDate(rs, "activatedDate");
final Integer statusEnum = rs.getInt("statusEnum");
final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum);
final Long officeId = rs.getLong("officeId");
final String officeName = rs.getString("officeName");
return new ShareAccountClientSummaryData(id, clientId, clientName, officeId, officeName, accountNo, productId, productName,
totalApprovedShares, totalPendingForApprovalShares, currency, activatedDate, status);
}
public String schema() {
return this.schema;
}
}
@Override
public String retrieveAccountNumberByAccountId(Long accountId) {
try {
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.welcomekit.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.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitReadPlatformService;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients/{clientId}/welcomekit")
@Component
@Tag(name = "Welcome Kit Dispatch", description = "Manages the dispatching and tracking of welcome kits for clients.")
@RequiredArgsConstructor
public class ClientWelcomeKitApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "WELCOMEKIT";
private final PlatformSecurityContext context;
private final WelcomeKitReadPlatformService readPlatformService;
private final WelcomeKitWritePlatformService writePlatformService;
private final FromJsonHelper fromJsonHelper;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all welcome kits for a Client")
public ClientWelcomeKitData getClientWelcomeKits(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.readPlatformService.retrieveWelcomeKitsByClientId(clientId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a new welcome kit dispatch")
public CommandProcessingResult dispatchWelcomeKit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromJsonHelper.parse(apiRequestBodyAsJson), this.fromJsonHelper);
return this.writePlatformService.dispatchWelcomeKit(clientId, command);
}
}
@@ -0,0 +1,67 @@
/**
* 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.welcomekit.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.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitReadPlatformService;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/welcomekit")
@Component
@Tag(name = "Welcome Kit Dispatch", description = "Manages the dispatching and tracking of welcome kits for clients.")
@RequiredArgsConstructor
public class WelcomeKitApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "WELCOMEKIT";
private final PlatformSecurityContext context;
private final WelcomeKitWritePlatformService writePlatformService;
private final FromJsonHelper fromJsonHelper;
@PUT
@Path("{welcomeKitId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update the status of an existing dispatch")
public CommandProcessingResult updateWelcomeKitStatus(
@PathParam("welcomeKitId") @Parameter(description = "welcomeKitId") final Long welcomeKitId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
final JsonCommand command = JsonCommand.fromJsonElement(welcomeKitId, this.fromJsonHelper.parse(apiRequestBodyAsJson), this.fromJsonHelper);
return this.writePlatformService.updateWelcomeKitStatus(welcomeKitId, command);
}
}
@@ -0,0 +1,33 @@
/**
* 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.welcomekit.data;
import java.io.Serializable;
import java.util.List;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ClientWelcomeKitData implements Serializable {
private final String clientName;
private final boolean canDispatchNew;
private final List<WelcomeKitDispatchData> dispatches;
}
@@ -0,0 +1,40 @@
/**
* 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.welcomekit.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WelcomeKitDispatchData implements Serializable {
private final Long id;
private final Long clientId;
private final String clientName;
private final String deliveryAddress;
private final String deliveryMode;
private final String deliveryModeOtherText;
private final String trackingId;
private final String status;
private final LocalDate dispatchedOnDate;
private final LocalDate deliveredOnDate;
}
@@ -0,0 +1,118 @@
/**
* 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.welcomekit.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "m_welcome_kit_dispatch")
@EntityListeners(AuditingEntityListener.class)
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "delivery_address", length = 500, nullable = false)
private String deliveryAddress;
@Column(name = "delivery_mode", length = 50, nullable = false)
private String deliveryMode;
@Column(name = "delivery_mode_other_text", length = 255)
private String deliveryModeOtherText;
@Column(name = "tracking_id", length = 100, nullable = false, unique = true)
private String trackingId;
@Column(name = "status", length = 30, nullable = false)
private String status;
@Column(name = "dispatched_on_date", nullable = false)
private LocalDate dispatchedOnDate;
@Column(name = "delivered_on_date")
private LocalDate deliveredOnDate;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_date", updatable = false)
private LocalDateTime createdDate;
@LastModifiedBy
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@LastModifiedDate
@Column(name = "last_modified_date")
private LocalDateTime lastModifiedDate;
protected WelcomeKitDispatch() {
// Default constructor for JPA
}
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final String status,
final LocalDate dispatchedOnDate) {
this.client = client;
this.deliveryAddress = deliveryAddress;
this.deliveryMode = deliveryMode;
this.deliveryModeOtherText = deliveryModeOtherText;
this.trackingId = trackingId;
this.status = status;
this.dispatchedOnDate = dispatchedOnDate;
}
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
}
public Client getClient() {
return this.client;
}
public String getStatus() {
return this.status;
}
public void updateStatus(final String newStatus, final LocalDate deliveredOnDate) {
this.status = newStatus;
if ("DELIVERED".equals(newStatus)) {
this.deliveredOnDate = deliveredOnDate;
}
}
}
@@ -0,0 +1,28 @@
/**
* 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.welcomekit.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
}
@@ -0,0 +1,26 @@
/**
* 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.welcomekit.service;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
public interface WelcomeKitReadPlatformService {
ClientWelcomeKitData retrieveWelcomeKitsByClientId(Long clientId);
}
@@ -0,0 +1,103 @@
/**
* 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.welcomekit.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatformService {
private final JdbcTemplate jdbcTemplate;
@Override
public ClientWelcomeKitData retrieveWelcomeKitsByClientId(Long clientId) {
final WelcomeKitDispatchMapper rm = new WelcomeKitDispatchMapper();
final String sql = "SELECT " + rm.schema() + " WHERE w.client_id = ? ORDER BY w.created_date DESC";
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
String clientName = this.jdbcTemplate.queryForObject("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
boolean canDispatchNew = true;
if (!dispatches.isEmpty()) {
WelcomeKitDispatchData latest = dispatches.get(0);
if ("RETURNED".equals(latest.getStatus())) {
canDispatchNew = true;
} else {
canDispatchNew = false;
}
}
return ClientWelcomeKitData.builder()
.clientName(clientName)
.canDispatchNew(canDispatchNew)
.dispatches(dispatches)
.build();
}
private static final class WelcomeKitDispatchMapper implements RowMapper<WelcomeKitDispatchData> {
public String schema() {
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
+ " w.delivery_address as deliveryAddress, w.delivery_mode as deliveryMode, "
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate "
+ " FROM m_welcome_kit_dispatch w "
+ " JOIN m_client c ON c.id = w.client_id ";
}
@Override
public WelcomeKitDispatchData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String deliveryAddress = rs.getString("deliveryAddress");
final String deliveryMode = rs.getString("deliveryMode");
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
final String trackingId = rs.getString("trackingId");
final String status = rs.getString("status");
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
return WelcomeKitDispatchData.builder()
.id(id)
.clientId(clientId)
.clientName(clientName)
.deliveryAddress(deliveryAddress)
.deliveryMode(deliveryMode)
.deliveryModeOtherText(deliveryModeOtherText)
.trackingId(trackingId)
.status(status)
.dispatchedOnDate(dispatchedOnDate)
.deliveredOnDate(deliveredOnDate)
.build();
}
}
}
@@ -0,0 +1,29 @@
/**
* 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.welcomekit.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface WelcomeKitWritePlatformService {
CommandProcessingResult dispatchWelcomeKit(Long clientId, JsonCommand command);
CommandProcessingResult updateWelcomeKitStatus(Long welcomeKitId, JsonCommand command);
}
@@ -0,0 +1,112 @@
/**
* 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.welcomekit.service;
import java.time.LocalDate;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatch;
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatchRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatformService {
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final WelcomeKitDispatchRepository welcomeKitDispatchRepository;
@Transactional
@Override
public CommandProcessingResult dispatchWelcomeKit(final Long clientId, final JsonCommand command) {
this.context.authenticatedUser();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryAddress.cannot.be.blank",
"Delivery address cannot be blank.");
}
if (deliveryMode == null || deliveryMode.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
"Delivery mode cannot be blank.");
}
if (dispatchedOnDate == null) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
"Dispatched on date cannot be blank.");
}
final String trackingId = "WK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, deliveryMode, deliveryModeOtherText,
trackingId, dispatchedOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withClientId(clientId)
.withEntityId(newDispatch.getId())
.build();
}
@Transactional
@Override
public CommandProcessingResult updateWelcomeKitStatus(final Long welcomeKitId, final JsonCommand command) {
this.context.authenticatedUser();
final WelcomeKitDispatch dispatch = this.welcomeKitDispatchRepository.findById(welcomeKitId).orElseThrow(
() -> new PlatformDataIntegrityException("error.msg.welcomekit.not.found", "Welcome kit not found."));
final String status = command.stringValueOfParameterNamed("status");
if (status == null || status.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
}
LocalDate deliveredOnDate = null;
if ("DELIVERED".equals(status)) {
deliveredOnDate = LocalDate.now();
}
dispatch.updateStatus(status, deliveredOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withClientId(dispatch.getClient().getId())
.withEntityId(dispatch.getId())
.build();
}
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -435,6 +435,7 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0416_create_user_agreement_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0417_add_first_login_gate_config.xml" relativeToChangelogFile="true"/>
@@ -496,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>
@@ -0,0 +1,38 @@
<?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="0415_welcome_kit_dispatch" author="CBS" runOnChange="false">
<sql>
CREATE TABLE m_welcome_kit_dispatch (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES m_client(id),
delivery_address VARCHAR(500) NOT NULL,
delivery_mode VARCHAR(50) NOT NULL,
delivery_mode_other_text VARCHAR(255),
tracking_id VARCHAR(100) NOT NULL UNIQUE,
status VARCHAR(30) NOT NULL DEFAULT 'DISPATCHED',
dispatched_on_date DATE NOT NULL,
delivered_on_date DATE,
created_by BIGINT,
created_date TIMESTAMP NOT NULL DEFAULT NOW(),
last_modified_by BIGINT,
last_modified_date TIMESTAMP
);
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'READ_WELCOMEKIT', 'WELCOMEKIT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_WELCOMEKIT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_WELCOMEKIT', 'WELCOMEKIT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_WELCOMEKIT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'UPDATE_WELCOMEKIT', 'WELCOMEKIT', 'UPDATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_WELCOMEKIT');
</sql>
</changeSet>
</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>
+2 -1
View File
@@ -16,10 +16,11 @@
# specific language governing permissions and limitations
# under the License.
#
org.gradle.jvmargs=-Xmx6g --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports=java.naming/com.sun.jndi.ldap=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.management/javax.management=ALL-UNNAMED --add-opens=java.naming/javax.naming=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=null
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports=java.naming/com.sun.jndi.ldap=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.management/javax.management=ALL-UNNAMED --add-opens=java.naming/javax.naming=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=null
buildType=BUILD
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.workers.max=2
org.gradle.daemon.idletimeout=10800000
# Temporarily disabled until we fix configuration cache compatibility
#org.gradle.configuration-cache=true
+2097
View File
File diff suppressed because it is too large Load Diff
+2115
View File
File diff suppressed because it is too large Load Diff
+2577
View File
File diff suppressed because it is too large Load Diff
+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
@@ -0,0 +1,10 @@
17e0dab43 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
67810bfae Add Welcome Kit Dispatch feature and Share Account summary API
7c15fbb0c fix(documentmanagement): close leaked file handles causing Windows re-upload failures; add entity KYC document CodeValues
6e3f195de Merge remote-tracking branch 'origin/uate' into uate
a65d80125 feat(clients): maker-checker for client creation and edits (backend)
f45d5890e CTR/STR report and Risk Category auto mark on bases of person occupation and anual salary enter
3c58bb231 fix(clients): pwdCategoryOptions on edit template, minor-age threshold to 18, minor KYC identity-proof bypass
aabda18b6 feat(clients): enforce Mother + Father/Caretaker family members on client activation
0e5671b70 Merge remote-tracking branch 'origin/uate' into uate
67214ec0f fix(clients): stale signature bug, CKYC fixes, monthly turnover/annual income, senior citizen/PWD classification
+46
View File
@@ -0,0 +1,46 @@
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
$headers = @{
"fineract-platform-tenantid" = "default"
"Content-Type" = "application/json"
}
$body = '{"username":"mifos", "password":"password"}'
Write-Host "--- AUTHENTICATION ---"
$authResponse = Invoke-RestMethod -Method Post -Uri "https://localhost:8443/fineract-provider/api/v1/authentication?username=mifos&password=password" -Headers $headers
$authKey = $authResponse.base64EncodedAuthenticationKey
$headers.Add("Authorization", "Basic $authKey")
Write-Host "`n--- 1. GET /v1/clients/95/welcomekit ---"
$res1 = Invoke-RestMethod -Method Get -Uri "https://localhost:8443/fineract-provider/api/v1/clients/95/welcomekit" -Headers $headers
$res1 | ConvertTo-Json -Depth 10
Write-Host "`n--- 2. POST /v1/clients/95/welcomekit ---"
$postBody = @{
deliveryAddress = "123 Main St, Springfield"
deliveryMode = "POSTMAN"
dispatchedOnDate = "30 July 2026"
dateFormat = "dd MMMM yyyy"
locale = "en"
} | ConvertTo-Json
Write-Host "Request Body: $postBody"
$res2 = Invoke-RestMethod -Method Post -Uri "https://localhost:8443/fineract-provider/api/v1/clients/95/welcomekit" -Headers $headers -Body $postBody
$res2 | ConvertTo-Json -Depth 10
$welcomeKitId = $res2.resourceId
Write-Host "`n--- 3. GET /v1/clients/95/welcomekit (after POST) ---"
$res3 = Invoke-RestMethod -Method Get -Uri "https://localhost:8443/fineract-provider/api/v1/clients/95/welcomekit" -Headers $headers
$res3 | ConvertTo-Json -Depth 10
Write-Host "`n--- 4. PUT /v1/welcomekit/$welcomeKitId ---"
$putBody = @{
status = "DELIVERED"
} | ConvertTo-Json
Write-Host "Request Body: $putBody"
$res4 = Invoke-RestMethod -Method Put -Uri "https://localhost:8443/fineract-provider/api/v1/welcomekit/$welcomeKitId" -Headers $headers -Body $putBody
$res4 | ConvertTo-Json -Depth 10
Write-Host "`n--- 5. GET /v1/clients/95/welcomekit (after PUT) ---"
$res5 = Invoke-RestMethod -Method Get -Uri "https://localhost:8443/fineract-provider/api/v1/clients/95/welcomekit" -Headers $headers
$res5 | ConvertTo-Json -Depth 10