diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java
index a8443f749..af0fb4ddd 100644
--- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java
+++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java
@@ -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";
diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java b/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java
index 325cc5cd2..2c437fdcd 100644
--- a/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java
+++ b/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java
@@ -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();
+ }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java
index 341754aac..1d1c4c0d9 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java
@@ -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 })
diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java
new file mode 100644
index 000000000..009fb1158
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java
@@ -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);
+ }
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java
index 5cf0fbd8a..d90e3f28c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java
@@ -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);
diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java
index edc4180c1..bfab9ce27 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java
@@ -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) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java
index e07746e6c..c5fc22384 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java
@@ -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);
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java
index 6391f8268..e7c7d6c37 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java
@@ -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);
diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties
index 70fbe79ba..108e7421a 100644
--- a/fineract-provider/src/main/resources/application.properties
+++ b/fineract-provider/src/main/resources/application.properties
@@ -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}
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 518b7221b..d0c837041 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
@@ -587,6 +587,19 @@
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
index 5c0ea6560..06323958f 100644
--- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
@@ -43,8 +43,16 @@
-
-
+
@@ -54,4 +62,6 @@
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml
new file mode 100644
index 000000000..2f6589894
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ SELECT setval(pg_get_serial_sequence('job', 'id'), COALESCE((SELECT MAX(id) FROM job), 1), true);
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml
new file mode 100644
index 000000000..295fd1f91
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE'
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE' AND can_maker_checker = false
+
+
+
+ code = 'DELETE_OFFICE'
+
+
+
+