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.ClientStatus;
|
||||
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.ClientHasNoStaffException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException;
|
||||
@@ -1457,6 +1458,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
runEntityDatatableCheck(clientId, client.getLegalForm());
|
||||
|
||||
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);
|
||||
CommandProcessingResult result = openSavingsAccount(client, fmt);
|
||||
clientRepository.saveAndFlush(client);
|
||||
|
||||
@@ -449,6 +449,9 @@
|
||||
<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/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
|
||||
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
|
||||
|
||||
+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