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:
2026-07-29 10:31:10 +05:30
co-authored by Claude Sonnet 5
parent 3c58bb231f
commit a65d801250
19 changed files with 1091 additions and 0 deletions
@@ -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
@@ -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>
@@ -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>
@@ -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>