feat(clients): maker-checker for client creation and edits (backend)
- Client activation now requires a different user than the one who created the client
(client.getCreatedBy() vs current user) — no bypass, including CHECKER_SUPER_USER, since a
bypass would recreate the exact "role bypasses the hold" loophole this closes. Client creation
itself is unaffected (the wizard needs a real client id immediately for photo/KYC/address/family
follow-up uploads), only activation is gated.
- New maker-checker queue for Client edits, modelled on this codebase's existing
m_share_account_pending_edit / chequebook patterns rather than Fineract's native
command-approval engine (which any ALL_FUNCTIONS role bypasses unconditionally):
- m_client_pending_edit (migrations 0429-0431): stores the maker's raw edit payload plus a
point-in-time "before" snapshot (original_json, extracted from the frontend's _originalValues
key) so the checker's diff stays accurate even after the edit is approved/rejected, when the
live client no longer reflects the pre-edit state.
- ClientEditWritePlatformService: submitEditForApproval() stages the edit (refusing a second
submission while one's already pending, via a nullable-but-unique pending_lock_key);
approveEdit() replays the payload through the existing updateClient() so all of Client's
validation/change-detection runs unchanged, just deferred; rejectEdit() discards it. The maker
can never approve/reject their own submission.
- ClientEditApiResource: /clients/{id}/edit (submit), /clients/edit/pending (paginated +
status/clientName/date-range filterable list, mirrors /chequebook/pending-requests),
/clients/{id}/edit/approve, /clients/{id}/edit/reject. New permissions (migration 0430):
SUBMITEDIT_CLIENT, APPROVEEDIT_CLIENT, REJECTEDIT_CLIENT, APPROVEEDIT_CLIENT_CHECKER (the
last one surfaces pending edits in the generic Checker Inbox too).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+164
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 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.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.DefaultValue;
|
||||||
|
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.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.Context;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
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.service.ClientEditWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/clients")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Client Edit Approval",
|
||||||
|
description = "Maker-checker workflow for editing an existing Client's core profile details. Edits are held in "
|
||||||
|
+ "m_client_pending_edit (a pending row there is itself the lock) and only applied to the real client once a "
|
||||||
|
+ "different user (the checker) approves — see /clients/{clientId}/edit/approve and /reject below.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientEditApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||||
|
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||||
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||||
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<ClientPendingEditData> pendingEditApiJsonSerializer;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{clientId}/edit")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Submit a details edit for a Client",
|
||||||
|
description = "Stages the proposed changes in m_client_pending_edit instead of applying them immediately — see "
|
||||||
|
+ "/clients/{clientId}/edit/approve and /reject below.")
|
||||||
|
public String submitEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CLIENT");
|
||||||
|
|
||||||
|
final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
|
||||||
|
this.fromApiJsonHelper);
|
||||||
|
|
||||||
|
final CommandProcessingResult result = this.clientEditWritePlatformService.submitEditForApproval(clientId, command);
|
||||||
|
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("edit/pending")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List Client details edits pending approval",
|
||||||
|
description = "Supports status/clientName/fromDate/toDate filtering (fromDate/toDate as yyyy-MM-dd, inclusive, filtered "
|
||||||
|
+ "on submitted date; clientName is a case-insensitive substring match) plus offset/limit pagination — mirrors "
|
||||||
|
+ "/chequebook/pending-requests.")
|
||||||
|
public Page<ClientPendingEditData> retrieveAllPendingEdits(@QueryParam("status") final String status,
|
||||||
|
@QueryParam("clientName") final String clientName, @QueryParam("fromDate") final String fromDate,
|
||||||
|
@QueryParam("toDate") final String toDate, @QueryParam("offset") @DefaultValue("0") final Integer offset,
|
||||||
|
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final int safeLimit = Math.min(limit, 200);
|
||||||
|
return this.clientPendingEditReadPlatformService.retrieveAllPendingEdits(status, clientName, fromDate, toDate, offset, safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{clientId}/edit/pending")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve the pending details edit for a Client, if any")
|
||||||
|
public String retrievePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||||
|
@Context final UriInfo uriInfo) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final ClientPendingEditData pendingEdit = this.clientPendingEditReadPlatformService.retrievePendingEdit(clientId);
|
||||||
|
|
||||||
|
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||||
|
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{clientId}/edit/approve")
|
||||||
|
@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) {
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{clientId}/edit/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject a pending Client details edit",
|
||||||
|
description = "Discards the proposed changes without applying them. The maker of the edit cannot also reject it — no exceptions.")
|
||||||
|
public String rejectPendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
|
||||||
|
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CLIENT")) {
|
||||||
|
throw new NoAuthorizationException("User has no authority to reject client edits");
|
||||||
|
}
|
||||||
|
|
||||||
|
String rejectReason = null;
|
||||||
|
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
|
||||||
|
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
|
||||||
|
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommandProcessingResult result = this.clientEditWritePlatformService.rejectEdit(clientId, rejectReason);
|
||||||
|
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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.time.OffsetDateTime;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ClientPendingEditData {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientDisplayName;
|
||||||
|
private final String payloadJson;
|
||||||
|
private final String originalJson;
|
||||||
|
private final String status;
|
||||||
|
private final Long makerId;
|
||||||
|
private final String makerName;
|
||||||
|
private final OffsetDateTime submittedDate;
|
||||||
|
private final Long checkerId;
|
||||||
|
private final String checkerName;
|
||||||
|
private final OffsetDateTime checkedDate;
|
||||||
|
private final String rejectReason;
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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.OffsetDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bespoke maker-checker for Client edits — modelled on {@link ClientPendingEditStatus} /
|
||||||
|
* {@code m_share_account_pending_edit} elsewhere in this codebase rather than Fineract's native
|
||||||
|
* command-approval engine, because that engine's hold is bypassed by any role carrying
|
||||||
|
* ALL_FUNCTIONS (Super user, Clerk in this tenant), making it a no-op for this CBS's actual admin
|
||||||
|
* accounts.
|
||||||
|
*
|
||||||
|
* <p>Holds the maker's raw PUT /clients/{id}/edit payload as-is (rather than one typed column per
|
||||||
|
* editable Client field, which — given how many fields Client actually has across general,
|
||||||
|
* financial-classification and KYC-adjacent data — would mean a huge, fragile shadow-column table
|
||||||
|
* that has to be kept in lockstep with the entity forever). Approval reconstructs a
|
||||||
|
* {@code JsonCommand} from the stored JSON and replays it through the existing
|
||||||
|
* {@code ClientWritePlatformService.updateClient(...)}, so all of Client's existing validation and
|
||||||
|
* change-detection logic runs exactly as it would for a direct edit — just deferred until a
|
||||||
|
* different user approves it. The maker can never approve/reject their own submission — enforced
|
||||||
|
* in the write service, not just by role, with no bypass for CHECKER_SUPER_USER.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_client_pending_edit")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@Column(name = "payload_json", nullable = false)
|
||||||
|
private String payloadJson;
|
||||||
|
|
||||||
|
/** Point-in-time "before" snapshot the maker's client had when the edit was submitted — see
|
||||||
|
* client-edit-diff.util.ts's buildOriginalValuesSnapshot(). Null for edits submitted before
|
||||||
|
* this column existed. Lets the checker's compare view show a true historical diff regardless
|
||||||
|
* of whether the live client has since changed (e.g. once approved, current == proposed). */
|
||||||
|
@Column(name = "original_json")
|
||||||
|
private String originalJson;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "maker_id", nullable = false)
|
||||||
|
private Long makerId;
|
||||||
|
|
||||||
|
@Column(name = "submitted_date", nullable = false)
|
||||||
|
private OffsetDateTime submittedDate;
|
||||||
|
|
||||||
|
@Column(name = "checker_id")
|
||||||
|
private Long checkerId;
|
||||||
|
|
||||||
|
@Column(name = "checked_date")
|
||||||
|
private OffsetDateTime checkedDate;
|
||||||
|
|
||||||
|
@Column(name = "reject_reason", length = 500)
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||||
|
private String pendingLockKey;
|
||||||
|
|
||||||
|
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||||
|
final AppUser maker) {
|
||||||
|
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||||
|
edit.client = client;
|
||||||
|
edit.payloadJson = payloadJson;
|
||||||
|
edit.originalJson = originalJson;
|
||||||
|
edit.status = ClientPendingEditStatus.PENDING.name();
|
||||||
|
edit.makerId = maker.getId();
|
||||||
|
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||||
|
return edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String lockKeyFor(final Long clientId) {
|
||||||
|
return "CLIENT_EDIT:" + clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPending() {
|
||||||
|
return ClientPendingEditStatus.PENDING.name().equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The maker can never be their own checker — a distinct human must approve/reject an edit. */
|
||||||
|
public boolean isMadeBy(final Long userId) {
|
||||||
|
return this.makerId != null && this.makerId.equals(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final AppUser checker) {
|
||||||
|
this.status = ClientPendingEditStatus.APPROVED.name();
|
||||||
|
this.checkerId = checker.getId();
|
||||||
|
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
this.pendingLockKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(final AppUser checker, final String reason) {
|
||||||
|
this.status = ClientPendingEditStatus.REJECTED.name();
|
||||||
|
this.checkerId = checker.getId();
|
||||||
|
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
this.rejectReason = reason;
|
||||||
|
this.pendingLockKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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 java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ClientPendingEditRepository extends JpaRepository<ClientPendingEdit, Long> {
|
||||||
|
|
||||||
|
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||||
|
|
||||||
|
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||||
|
}
|
||||||
+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.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientPendingEditRepositoryWrapper {
|
||||||
|
|
||||||
|
private final ClientPendingEditRepository repository;
|
||||||
|
|
||||||
|
public ClientPendingEdit findPendingByClientIdWithNotFoundDetection(final Long clientId) {
|
||||||
|
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name())
|
||||||
|
.orElseThrow(() -> new ClientNoPendingEditException(clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ClientPendingEdit> findPendingByClientId(final Long clientId) {
|
||||||
|
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ClientPendingEdit> findAllPending() {
|
||||||
|
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||||
|
return this.repository.saveAndFlush(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** Lifecycle of a {@link ClientPendingEdit}. Terminal once APPROVED or REJECTED. */
|
||||||
|
public enum ClientPendingEditStatus {
|
||||||
|
|
||||||
|
PENDING, //
|
||||||
|
APPROVED, //
|
||||||
|
REJECTED; //
|
||||||
|
}
|
||||||
+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.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the user attempting to activate a client is the same user who created it — a
|
||||||
|
* four-eyes (maker-checker) requirement with no bypass, including for users holding
|
||||||
|
* CHECKER_SUPER_USER, since a bypass would recreate the exact "ALL_FUNCTIONS/super user ignores
|
||||||
|
* the hold" loophole this check exists to close.
|
||||||
|
*/
|
||||||
|
public class ClientActivationSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public ClientActivationSelfApprovalNotAllowedException() {
|
||||||
|
super("error.msg.client.activation.self.approval.not.allowed",
|
||||||
|
"You cannot activate a client you created yourself — a different user must activate it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.client.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
public class ClientEditPendingApprovalException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public ClientEditPendingApprovalException(final Long clientId) {
|
||||||
|
super("error.msg.client.edit.pending.approval", "Client with identifier " + clientId + " already has an edit pending approval",
|
||||||
|
clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the user attempting to approve/reject a Client's pending edit is the same user who
|
||||||
|
* submitted it — no bypass, including for users holding CHECKER_SUPER_USER, since a bypass would
|
||||||
|
* recreate the exact "role bypasses the hold" loophole this check exists to close.
|
||||||
|
*/
|
||||||
|
public class ClientEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public ClientEditSelfApprovalNotAllowedException(final Long clientId) {
|
||||||
|
super("error.msg.client.edit.self.approval.not.allowed",
|
||||||
|
"You cannot approve or reject an edit you submitted yourself for client " + clientId
|
||||||
|
+ " — a different user must review it.",
|
||||||
|
clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.client.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
public class ClientNoPendingEditException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public ClientNoPendingEditException(final Long clientId) {
|
||||||
|
super("error.msg.client.no.pending.edit", "Client with identifier " + clientId + " has no edit pending approval", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
/** Maker-checker for Client edits — see {@link org.apache.fineract.portfolio.client.domain.ClientPendingEdit}. */
|
||||||
|
public interface ClientEditWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult approveEdit(Long clientId);
|
||||||
|
|
||||||
|
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 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 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.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||||
|
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.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
||||||
|
|
||||||
|
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||||
|
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientRepositoryWrapper clientRepository;
|
||||||
|
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||||
|
private final ClientWritePlatformService clientWritePlatformService;
|
||||||
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult submitEditForApproval(final Long clientId, final JsonCommand command) {
|
||||||
|
final AppUser maker = this.context.authenticatedUser();
|
||||||
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
// Four-eyes lock: refuse a second maker while one edit is already awaiting approval for
|
||||||
|
// this client, rather than silently overwriting or queueing it — matches
|
||||||
|
// m_share_account_pending_edit / m_savings_product_temp elsewhere in this codebase.
|
||||||
|
if (this.pendingEditRepository.findPendingByClientId(clientId).isPresent()) {
|
||||||
|
throw new ClientEditPendingApprovalException(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frontend rides its point-in-time "before" snapshot along under _originalValues so
|
||||||
|
// the checker's compare view can show a true historical diff later, even after the edit
|
||||||
|
// has been applied (by then the live client equals the proposed values, not the original
|
||||||
|
// ones — see client-edit-diff.util.ts). Split it out here: the rest replays through
|
||||||
|
// updateClient() at approval time and must not carry an unrecognised parameter.
|
||||||
|
final JsonElement parsedPayload = this.fromApiJsonHelper.parse(command.json());
|
||||||
|
String originalJson = null;
|
||||||
|
String cleanPayloadJson = command.json();
|
||||||
|
if (parsedPayload != null && parsedPayload.isJsonObject()) {
|
||||||
|
final JsonObject payloadObject = parsedPayload.getAsJsonObject();
|
||||||
|
final JsonElement originalValues = payloadObject.remove(ORIGINAL_VALUES_PAYLOAD_KEY);
|
||||||
|
if (originalValues != null) {
|
||||||
|
originalJson = originalValues.toString();
|
||||||
|
}
|
||||||
|
cleanPayloadJson = payloadObject.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final ClientPendingEdit pendingEdit = ClientPendingEdit.submit(client, cleanPayloadJson, originalJson, maker);
|
||||||
|
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withOfficeId(client.officeId()) //
|
||||||
|
.withClientId(clientId) //
|
||||||
|
.withEntityId(pendingEdit.getId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult approveEdit(final Long clientId) {
|
||||||
|
final AppUser checker = this.context.authenticatedUser();
|
||||||
|
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
// No bypass — including CHECKER_SUPER_USER — a bypass here would recreate the exact
|
||||||
|
// "role bypasses the hold" loophole this whole feature exists to close.
|
||||||
|
if (pendingEdit.isMadeBy(checker.getId())) {
|
||||||
|
throw new ClientEditSelfApprovalNotAllowedException(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||||
|
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||||
|
|
||||||
|
pendingEdit.approve(checker);
|
||||||
|
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult rejectEdit(final Long clientId, final String rejectReason) {
|
||||||
|
final AppUser checker = this.context.authenticatedUser();
|
||||||
|
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
if (pendingEdit.isMadeBy(checker.getId())) {
|
||||||
|
throw new ClientEditSelfApprovalNotAllowedException(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingEdit.reject(checker, rejectReason);
|
||||||
|
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withClientId(clientId) //
|
||||||
|
.withEntityId(pendingEdit.getId()) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.core.service.Page;
|
||||||
|
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||||
|
|
||||||
|
public interface ClientPendingEditReadPlatformService {
|
||||||
|
|
||||||
|
ClientPendingEditData retrievePendingEdit(Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param status defaults to PENDING when blank
|
||||||
|
* @param clientName case-insensitive substring match against the client's display name
|
||||||
|
* @param fromDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
|
||||||
|
* @param toDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
|
||||||
|
*/
|
||||||
|
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||||
|
Integer limit);
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.Page;
|
||||||
|
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||||
|
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEditReadPlatformService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private static final String SELECT_SQL = """
|
||||||
|
SELECT pe.id AS id, pe.client_id AS clientId, c.display_name AS clientDisplayName,
|
||||||
|
pe.payload_json AS payloadJson, pe.original_json AS originalJson, pe.status AS status,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
LEFT JOIN m_appuser checker ON checker.id = pe.checker_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
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);
|
||||||
|
return ClientPendingEditData.builder() //
|
||||||
|
.id(rs.getLong("id")) //
|
||||||
|
.clientId(rs.getLong("clientId")) //
|
||||||
|
.clientDisplayName(rs.getString("clientDisplayName")) //
|
||||||
|
.payloadJson(rs.getString("payloadJson")) //
|
||||||
|
.originalJson(rs.getString("originalJson")) //
|
||||||
|
.status(rs.getString("status")) //
|
||||||
|
.makerId(rs.getLong("makerId")) //
|
||||||
|
.makerName(rs.getString("makerName")) //
|
||||||
|
.submittedDate(submittedDate) //
|
||||||
|
.checkerId(JdbcSupport.getLong(rs, "checkerId")) //
|
||||||
|
.checkerName(rs.getString("checkerName")) //
|
||||||
|
.checkedDate(checkedDate) //
|
||||||
|
.rejectReason(rs.getString("rejectReason")) //
|
||||||
|
.build();
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientPendingEditData retrievePendingEdit(final Long clientId) {
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForObject(SELECT_SQL + " WHERE pe.client_id = ? AND pe.status = 'PENDING'", MAPPER, clientId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new ClientNoPendingEditException(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ClientPendingEditData> retrieveAllPendingEdits(final String status, final String clientName, final String fromDate,
|
||||||
|
final String toDate, final Integer offset, final Integer limit) {
|
||||||
|
final StringBuilder where = new StringBuilder(" WHERE 1=1");
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
if (StringUtils.isNotBlank(status)) {
|
||||||
|
where.append(" AND pe.status = ?");
|
||||||
|
args.add(status.trim().toUpperCase());
|
||||||
|
} else {
|
||||||
|
where.append(" AND pe.status = 'PENDING'");
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(clientName)) {
|
||||||
|
where.append(" AND c.display_name ILIKE ?");
|
||||||
|
args.add("%" + clientName.trim() + "%");
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(fromDate)) {
|
||||||
|
where.append(" AND pe.submitted_date::date >= ?::date");
|
||||||
|
args.add(fromDate);
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(toDate)) {
|
||||||
|
where.append(" AND pe.submitted_date::date <= ?::date");
|
||||||
|
args.add(toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String countSql = "SELECT COUNT(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id" + where;
|
||||||
|
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
|
||||||
|
|
||||||
|
final int pageOffset = offset == null ? 0 : offset;
|
||||||
|
final int pageLimit = limit == null ? 10 : limit;
|
||||||
|
final String pageSql = SELECT_SQL + where + " ORDER BY pe.submitted_date DESC LIMIT ? OFFSET ?";
|
||||||
|
final List<Object> pageArgs = new ArrayList<>(args);
|
||||||
|
pageArgs.add(pageLimit);
|
||||||
|
pageArgs.add(pageOffset);
|
||||||
|
final List<ClientPendingEditData> items = this.jdbcTemplate.query(pageSql, MAPPER, pageArgs.toArray());
|
||||||
|
|
||||||
|
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -74,6 +74,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientStatus;
|
import org.apache.fineract.portfolio.client.domain.ClientStatus;
|
||||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
||||||
|
import org.apache.fineract.portfolio.client.exception.ClientActivationSelfApprovalNotAllowedException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientActiveForUpdateException;
|
import org.apache.fineract.portfolio.client.exception.ClientActiveForUpdateException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientHasNoStaffException;
|
import org.apache.fineract.portfolio.client.exception.ClientHasNoStaffException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException;
|
import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException;
|
||||||
@@ -1457,6 +1458,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
runEntityDatatableCheck(clientId, client.getLegalForm());
|
runEntityDatatableCheck(clientId, client.getLegalForm());
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
// Four-eyes principle: whoever created this client (the maker) cannot also be the one
|
||||||
|
// who activates it (the checker) — no bypass, including for CHECKER_SUPER_USER, since
|
||||||
|
// that would recreate the exact "role bypasses the hold" loophole this closes.
|
||||||
|
if (client.getCreatedBy().isPresent() && client.getCreatedBy().get().equals(currentUser.getId())) {
|
||||||
|
throw new ClientActivationSelfApprovalNotAllowedException();
|
||||||
|
}
|
||||||
client.activate(currentUser, fmt, activationDate);
|
client.activate(currentUser, fmt, activationDate);
|
||||||
CommandProcessingResult result = openSavingsAccount(client, fmt);
|
CommandProcessingResult result = openSavingsAccount(client, fmt);
|
||||||
clientRepository.saveAndFlush(client);
|
clientRepository.saveAndFlush(client);
|
||||||
|
|||||||
@@ -449,6 +449,9 @@
|
|||||||
<include file="parts/0426_add_graduate_education_level.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0426_add_graduate_education_level.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0427_add_caretaker_relationship_value.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0427_add_caretaker_relationship_value.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0428_add_minor_kyc_id_proof_codes.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0428_add_minor_kyc_id_proof_codes.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0429_add_client_pending_edit_table.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0430_add_client_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0431_add_original_json_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||||
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
|
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
|
||||||
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
|
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
|
||||||
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
|
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Maker-checker for Client edits: the maker's PUT /clients/{id}/edit payload is staged here
|
||||||
|
(raw JSON, replayed through the existing ClientWritePlatformService.updateClient() on
|
||||||
|
approval, so all existing validation/business logic is reused unchanged) rather than
|
||||||
|
applied immediately. `pending_lock_key` is a nullable-but-unique column (NULL while not
|
||||||
|
pending) used to prevent a second maker from submitting another edit while one is already
|
||||||
|
awaiting approval for the same client — see m_share_account_pending_edit /
|
||||||
|
m_savings_product_temp for the same pattern elsewhere in this codebase. Resolved rows are
|
||||||
|
kept (status APPROVED/REJECTED) for audit history rather than deleted, which is exactly
|
||||||
|
why the lock can't be a plain UNIQUE(client_id) — a client could never be edited a second
|
||||||
|
time after its first edit was ever resolved. -->
|
||||||
|
<changeSet author="CBS" id="0429-1-create-client-pending-edit-table">
|
||||||
|
<createTable tableName="m_client_pending_edit">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="client_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_client_pending_edit_client" references="m_client(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="payload_json" type="TEXT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="maker_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_client_pending_edit_maker" references="m_appuser(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="submitted_date" type="TIMESTAMP WITH TIME ZONE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="checker_id" type="BIGINT">
|
||||||
|
<constraints nullable="true" foreignKeyName="fk_client_pending_edit_checker" references="m_appuser(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="checked_date" type="TIMESTAMP WITH TIME ZONE"/>
|
||||||
|
<column name="reject_reason" type="VARCHAR(500)"/>
|
||||||
|
<column name="pending_lock_key" type="VARCHAR(60)">
|
||||||
|
<constraints nullable="true" unique="true" uniqueConstraintName="uq_client_pending_edit_lock_key"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="CBS" id="0429-2-index-client-pending-edit-client-id">
|
||||||
|
<createIndex indexName="idx_client_pending_edit_client_id" tableName="m_client_pending_edit">
|
||||||
|
<column name="client_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="CBS" id="0429-3-index-client-pending-edit-status">
|
||||||
|
<createIndex indexName="idx_client_pending_edit_status" tableName="m_client_pending_edit">
|
||||||
|
<column name="status"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Permissions for the Client edit maker-checker workflow (ClientEditApiResource /
|
||||||
|
m_client_pending_edit). can_maker_checker=false on all four — these rows ARE the bespoke
|
||||||
|
gate itself, so layering Fineract's native command-hold on top would be redundant.
|
||||||
|
APPROVEEDIT_CLIENT_CHECKER (action_name ending "_CHECKER") makes pending Client edits also
|
||||||
|
show up in the generic Checker Inbox (GET /v1/makercheckers) for limited checkers whose
|
||||||
|
role holds it, mirroring 2022_add_product_checker_permissions.xml elsewhere in this repo. -->
|
||||||
|
<changeSet author="CBS" id="0430-1-add-client-edit-approval-permissions">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CLIENT'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="SUBMITEDIT_CLIENT"/>
|
||||||
|
<column name="entity_name" value="CLIENT"/>
|
||||||
|
<column name="action_name" value="SUBMITEDIT"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="APPROVEEDIT_CLIENT"/>
|
||||||
|
<column name="entity_name" value="CLIENT"/>
|
||||||
|
<column name="action_name" value="APPROVEEDIT"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="REJECTEDIT_CLIENT"/>
|
||||||
|
<column name="entity_name" value="CLIENT"/>
|
||||||
|
<column name="action_name" value="REJECTEDIT"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="APPROVEEDIT_CLIENT_CHECKER"/>
|
||||||
|
<column name="entity_name" value="CLIENT"/>
|
||||||
|
<column name="action_name" value="APPROVEEDIT_CHECKER"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Point-in-time "before" snapshot of the client's own values, captured by the frontend from
|
||||||
|
the record it loaded when the edit page was opened (see buildOriginalValuesSnapshot() in
|
||||||
|
client-edit-diff.util.ts) and submitted alongside the edit under the _originalValues key,
|
||||||
|
which ClientEditWritePlatformServiceImpl strips out before storing/replaying the rest as
|
||||||
|
payload_json. Needed because diffing the staged payload against the LIVE client (as the
|
||||||
|
checker UI originally did) shows nothing once an edit is approved — by then the live
|
||||||
|
client already equals the proposed values. Nullable: existing pending-edit rows created
|
||||||
|
before this column existed simply have no "before" snapshot to diff against. -->
|
||||||
|
<changeSet author="CBS" id="0431-1-add-original-json-to-client-pending-edit">
|
||||||
|
<addColumn tableName="m_client_pending_edit">
|
||||||
|
<column name="original_json" type="TEXT">
|
||||||
|
<constraints nullable="true"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user