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