feat(office): maker-checker delete for offices; fix DB password leak, migration ordering, job id drift
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s

Adds the missing DELETE /offices/{officeId} endpoint -- the UI's "Remove"
action called it already, but no backend route existed, so every attempt
failed with a generic error regardless of how empty the office was. Wired
the standard command-handler chain (CommandWrapperBuilder.deleteOffice ->
DeleteOfficeCommandHandler -> OfficeWritePlatformService.deleteOffice),
which pre-checks sub-offices/staff/clients/groups/users before deleting,
and catches any remaining FK violation on delete itself. The new
DELETE_OFFICE permission (0537) is seeded maker-checker-enabled (0538),
since this app runs with the global maker-checker switch on and office
deletion is irreversible -- 0538 is a separate changeset rather than
0537 itself because 0537 had already executed on local tenants by the
time that requirement surfaced, and editing an already-run changeset's
content breaks Liquibase's checksum validation on every tenant that ran it.

Also bundles a few DB/startup fixes found while getting this feature
running across local and a remote environment:
- application.properties: restores the tenant DB password default that a
  teammate's own local password had overwritten in a previous push, and
  adds server.error.include-message=always so a servlet-level rejection
  (e.g. TenantAwareBasicAuthenticationFilter on a missing/invalid
  Fineract-Platform-TenantId header) carries a real message instead of a
  bare {timestamp,status,error,path}.
- changelog-tenant.xml / final-changelog-tenant.xml: moves the
  0517/0518 table-creation includes ahead of 0525-0527/0533, which
  read/write those tables but previously ran first per
  db.changelog-master.xml's include order -- caused "relation ... does
  not exist" on any tenant reaching that point for the first time.
- 0536_sync_job_identity_sequence.xml: job.id is GENERATED BY DEFAULT AS
  IDENTITY; explicit-id job-seed inserts never advanced the backing
  sequence, so a later identity-generated insert could collide with an
  earlier explicit id ("duplicate key value violates ... job_pkey").
- ClientAllAccountsApiResource: NotFoundException(String) only sets the
  exception's own message for logs, it never reaches the response body,
  so a "client not found" 404 always came back empty; now built as a
  proper JSON error body. Also collapsed the message/defaultUserMessage/
  errors[0].defaultUserMessage triple down to one message field -- this
  endpoint never raises more than one error at a time, so the repetition
  added nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 15:16:07 +05:30
co-authored by Claude Sonnet 5
parent e33e0f23fe
commit 05bceea2d4
13 changed files with 301 additions and 8 deletions
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteOffice(final Long officeId) {
this.actionName = "DELETE";
this.entityName = "OFFICE";
this.entityId = officeId;
this.href = "/offices/" + officeId;
return this;
}
public CommandWrapperBuilder createOfficeTransaction() {
this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION";
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
public void delete(final Office entity) {
this.repository.delete(entity);
}
/**
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
* than at transaction-commit time where it can no longer be turned into a clean error message.
*/
public void deleteAndFlush(final Office entity) {
this.repository.delete(entity);
this.repository.flush();
}
}
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
@@ -200,6 +201,20 @@ public class OfficesApiResource {
return toApiJsonSerializer.serialize(result);
}
@DELETE
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
@ApiResponse(responseCode = "200", description = "OK")
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.deleteOffice(officeId) //
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
@PUT
@Path("/external-id/{externalId}")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,47 @@
/**
* 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.organisation.office.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.office.service.OfficeWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "OFFICE", action = "DELETE")
public class DeleteOfficeCommandHandler implements NewCommandSourceHandler {
private final OfficeWritePlatformService writePlatformService;
@Autowired
public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteOffice(command.entityId(), command);
}
}
@@ -27,6 +27,8 @@ public interface OfficeWritePlatformService {
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
CommandProcessingResult officeTransaction(JsonCommand command);
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
@@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final OfficeTransactionRepository officeTransactionRepository;
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
// repository for every one of them just to count rows would be a lot of new wiring for a check
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
// long tail that is left is caught by the try/catch around the delete itself.
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
}
}
/**
* Deletes an office outright -- distinct from every other office command, which only ever changes
* status or fields. There is no "closed" state for an office the way there is for a client or a
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
* failed with a generic error no matter how empty the office was; the endpoint it called never
* existed.
*
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
* structural block), then staff, clients and groups (the day-to-day reason an office still has
* anything in it), then any AppUser still homed there. Everything else that references m_office --
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
* violation on the delete itself is caught below and turned into one clear message instead. An
* office with real banking history behind it is not something this should make easy to remove either
* way, checked up front or not.
*/
@Transactional
@Override
@Caching(evict = {
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
this.context.authenticatedUser();
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
"Move or remove its sub-offices before deleting this office.");
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
"Reassign its staff to another office before deleting this office.");
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
"Transfer its clients to another office before deleting this office.");
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
"Transfer its groups to another office before deleting this office.");
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
"Reassign the users homed at this office before deleting it.");
try {
this.officeRepositoryWrapper.deleteAndFlush(office);
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
} catch (final PersistenceException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(officeId) //
.withOfficeId(officeId) //
.build();
}
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
final String plural, final String remedy) {
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
officeId);
if (count != null && count > 0) {
final String noun = count == 1 ? singular : plural;
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
}
}
@Transactional
@Override
public CommandProcessingResult officeTransaction(final JsonCommand command) {
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
applicationCurrencyRepository);
applicationCurrencyRepository, jdbcTemplate);
}
}
@@ -25,12 +25,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -234,11 +235,33 @@ public class ClientAllAccountsApiResource {
return client;
}
throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '"
throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '"
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
}
/**
* Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*}
* exception: {@code NotFoundException(String)} only sets the Java exception's own message (for
* logs), it never puts that text on the wire -- no exception mapper in this app converts a plain
* NotFoundException's message into the response body, so the caller always got
* {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message
* passed to the constructor was.
*
* One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0]
* .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape
* exists there to line up with Fineract's own multi-field validation errors, where {@code errors}
* really does carry one entry per invalid parameter. This endpoint never raises more than one
* error at a time, so the same sentence repeated three times said nothing extra, only made the
* body longer.
*/
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("httpStatusCode", httpStatus);
body.addProperty("message", message);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
private String resolveClientIp(final HttpServletRequest request) {
for (final String header : IP_HEADER_CANDIDATES) {
final String value = request.getHeader(header);
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -384,6 +384,14 @@ server.forward-headers-strategy=framework
server.port=${FINERACT_SERVER_PORT:8443}
server.servlet.context-path=${FINERACT_SERVER_SERVLET_CONTEXT_PATH:/fineract-provider}
server.compression.enabled=${FINERACT_SERVER_COMPRESSION_ENABLED:true}
# Spring Boot strips the "message" field from a servlet-container-level error response by default
# (server.error.include-message defaults to "never"), so any rejection that happens before a request
# reaches Jersey -- most commonly TenantAwareBasicAuthenticationFilter's response.sendError() when
# Fineract-Platform-TenantId is missing or invalid -- rendered as a bare {timestamp,status,error,path}
# with no indication of what was actually wrong. Every other error path in this app (JAX-RS's own
# exception mappers) already carries a message; this makes the pre-Jersey filter layer consistent
# with that instead of the framework default.
server.error.include-message=always
server.ssl.enabled=${FINERACT_SERVER_SSL_ENABLED:true}
server.ssl.protocol=TLS
@@ -407,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -587,6 +587,19 @@
<include file="parts/0522_create_deaf_remittance.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
<include file="parts/0524_rbi_dormancy_periods_all_products.xml" relativeToChangelogFile="true"/>
<!-- 0525/0526/0527/0533 below all read or write m_savings_daily_interest,
m_savings_interest_posting_run and m_savings_interest_posting_schedule, but those tables are
created by 0517/0518, which otherwise only run later, from final-changelog-tenant.xml
(db.changelog-master.xml includes changelog-tenant.xml before final-changelog-tenant.xml, so
without this, every one of the four fails with "relation ... does not exist" on any tenant
reaching this point for the first time; an existing tenant where 0517/0518 already ran via
final-changelog-tenant.xml is unaffected: Liquibase recognises the changeset by
id+author+filename and skips it there, it does not run twice). Included here, at the true
first point of use, rather than moving 0517/0518 out of final-changelog-tenant.xml: that file
still needs them for tenants already past this point in one order or the other, and moving a
changeset file after it has shipped changes nothing for a tenant that already recorded it. -->
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
<include file="parts/0525_savings_daily_interest_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0526_savings_interest_summary_view.xml" relativeToChangelogFile="true"/>
<include file="parts/0527_savings_daily_interest_one_row_per_account.xml" relativeToChangelogFile="true"/>
@@ -43,8 +43,16 @@
<include file="parts/0517_grant_checker_roles_hold_release_amount.xml" relativeToChangelogFile="true"/>
<include file="parts/0518_grant_teller_roles_read_institution_branch_profile.xml" relativeToChangelogFile="true"/>
<include file="parts/0516_seed_savings_interest_posting_control_gls.xml" relativeToChangelogFile="true"/>
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
<!-- 0517_create_savings_daily_interest_ledger.xml and 0518_create_savings_interest_posting_schedule.xml
used to be included here, but a changeset's identity to Liquibase is id+author+filename alone:
which include tag brings a file in is irrelevant to that identity, and its VALIDATE phase (which
runs before anything executes) hard-rejects the same id+author+filename appearing twice in the
flattened tree, preConditions or not. So the two lines cannot stay here AND also be included
earlier, in changelog-tenant.xml, ahead of 0525_savings_daily_interest_job.xml (which needs the
tables they create), see the comment there. Moved, not duplicated. A tenant that already ran
them from this location is unaffected: the recorded filename in its databasechangelog is the
same file path either way, so Liquibase still recognises it as already applied and skips it,
regardless of which include tag is now the one that reaches it first. -->
<include file="parts/0519_post_savings_interest_at_period_end.xml" relativeToChangelogFile="true"/>
<include file="parts/0520_drop_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0521_branch_day_register_vault_only_detail.xml" relativeToChangelogFile="true"/>
@@ -54,4 +62,6 @@
<include file="parts/0525_create_clerk_closure_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0526_seed_round_off_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0527_clerk_closure_request_voucher_no.xml" relativeToChangelogFile="true"/>
<include file="parts/0536_sync_job_identity_sequence.xml" relativeToChangelogFile="true"/>
<include file="parts/0537_seed_delete_office_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- job.id is GENERATED BY DEFAULT AS IDENTITY: an explicit-id INSERT (every job-seeding migration
in this codebase writes a fixed id so the row is portable and re-runnable) is allowed through,
but Postgres never advances the identity's backing sequence to account for it: that only
happens for a plain INSERT that lets the identity generate the value itself.
So the sequence silently falls behind the real MAX(id) every time a migration seeds a job with
an explicit id, and the FIRST migration afterwards that inserts a job WITHOUT one (there is no
way to tell in advance which one that will be) collides on "duplicate key value violates
unique constraint job_pkey", not because of anything wrong in that migration itself, but
because of every explicit-id insert that came before it on this exact tenant. Two tenants seeded
with a different subset/order of job migrations end up with different amounts of drift, which is
exactly why this broke on some tenants and not others.
The fix is a resync, not a rewrite of the seeding migrations: point the sequence at the current
true MAX(id) so the next identity-generated insert is guaranteed to land above every id already
in the table, however it got there. Safe to run on every tenant regardless of its individual
history, and safe to run again in the future if the same drift recurs: setval to MAX(id) is
idempotent, there is no state this could leave worse than it found. -->
<changeSet id="0536_sync_job_identity_sequence" author="cbs-nitesh">
<sql>
SELECT setval(pg_get_serial_sequence('job', 'id'), COALESCE((SELECT MAX(id) FROM job), 1), true);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- DELETE_OFFICE_CHECKER (the approval-side permission) has always been seeded by base Fineract,
but the maker-side DELETE_OFFICE permission it pairs with never was: nothing in this codebase
ever implemented an office-delete command to require it. The UI's "remove office" action calls
DELETE /offices/{officeId}, which had no backend endpoint at all: every attempt failed with a
generic error no matter how empty the office was. This seeds the missing permission so the new
DeleteOfficeCommandHandler's authorization check (OFFICE + DELETE, the same
entity_name/action_name pairing UPDATE_OFFICE already uses) has something to find.
can_maker_checker matches UPDATE_OFFICE/CREATE_OFFICE (false) here on purpose, even though this
app's global maker-checker switch is on and office deletion really should go through it: once
this changeset has executed anywhere, its content (this insert included) is locked in by
Liquibase's checksum: changing the value here after the fact breaks startup on every tenant that
already ran it. The actual flip to true lives in 0538 below, a changeset that never ran with the
old value, instead of being edited in here. -->
<changeSet id="0537_seed_delete_office_permission" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="DELETE_OFFICE"/>
<column name="entity_name" value="OFFICE"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Flips DELETE_OFFICE to maker-checker-enabled: deleting an office is irreversible, and this app
already runs with the global maker-checker configuration switched on (c_configuration
'maker-checker' = enabled). With this true, ConfigurationDomainServiceJpa#isMakerCheckerEnabledForTask
(keyed off this permission's own `code`, not the _CHECKER row) routes a plain user's delete
through CommandSourceService: the delete runs inside a transaction to surface any
data-integrity problem immediately, then that transaction is rolled back and the command is
queued as PENDING for a checker to approve, instead of the office actually being removed on the
maker's request alone. -->
<changeSet id="0538_enable_maker_checker_for_delete_office" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE' AND can_maker_checker = false</sqlCheck>
</preConditions>
<update tableName="m_permission">
<column name="can_maker_checker" valueBoolean="true"/>
<where>code = 'DELETE_OFFICE'</where>
</update>
</changeSet>
</databaseChangeLog>