diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java new file mode 100644 index 000000000..f2437a0c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientEditApiResource.java @@ -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 toApiJsonSerializer; + private final DefaultToApiJsonSerializer 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java new file mode 100644 index 000000000..aaeb9f137 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientPendingEditData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java new file mode 100644 index 000000000..96bb93a8d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEdit.java @@ -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. + * + *

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 { + + @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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java new file mode 100644 index 000000000..a819e8128 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepository.java @@ -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 { + + Optional findByClientIdAndStatus(Long clientId, String status); + + List findByStatusOrderBySubmittedDateDesc(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java new file mode 100644 index 000000000..97c1c8112 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditRepositoryWrapper.java @@ -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 findPendingByClientId(final Long clientId) { + return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name()); + } + + public List findAllPending() { + return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name()); + } + + public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) { + return this.repository.saveAndFlush(edit); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java new file mode 100644 index 000000000..306d4a4ee --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientPendingEditStatus.java @@ -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; // +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..c5416963e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientActivationSelfApprovalNotAllowedException.java @@ -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."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java new file mode 100644 index 000000000..88a28de33 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditPendingApprovalException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java new file mode 100644 index 000000000..eed902092 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientEditSelfApprovalNotAllowedException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java new file mode 100644 index 000000000..9f28f80cf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/exception/ClientNoPendingEditException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java new file mode 100644 index 000000000..4bb8011be --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java new file mode 100644 index 000000000..d56095e39 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientEditWritePlatformServiceImpl.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java new file mode 100644 index 000000000..aa3191581 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformService.java @@ -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 retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset, + Integer limit); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java new file mode 100644 index 000000000..be2e05f83 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientPendingEditReadPlatformServiceImpl.java @@ -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 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 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 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 pageArgs = new ArrayList<>(args); + pageArgs.add(pageLimit); + pageArgs.add(pageOffset); + final List items = this.jdbcTemplate.query(pageSql, MAPPER, pageArgs.toArray()); + + return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index fc3a80a35..86eb53575 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -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); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 2abef71f9..0c7df83d2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -449,6 +449,9 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml new file mode 100644 index 000000000..9d6a5b2d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0430_add_client_edit_approval_permissions.xml @@ -0,0 +1,64 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CLIENT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml new file mode 100644 index 000000000..f2b98ca86 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0431_add_original_json_to_client_pending_edit.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + +