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
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:
+8
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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() {
|
public CommandWrapperBuilder createOfficeTransaction() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICETRANSACTION";
|
this.entityName = "OFFICETRANSACTION";
|
||||||
|
|||||||
+11
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
|
|||||||
public void delete(final Office entity) {
|
public void delete(final Office entity) {
|
||||||
this.repository.delete(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -200,6 +201,20 @@ public class OfficesApiResource {
|
|||||||
return toApiJsonSerializer.serialize(result);
|
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
|
@PUT
|
||||||
@Path("/external-id/{externalId}")
|
@Path("/external-id/{externalId}")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -27,6 +27,8 @@ public interface OfficeWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult officeTransaction(JsonCommand command);
|
CommandProcessingResult officeTransaction(JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
||||||
|
|||||||
+75
@@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Caching;
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||||
private final OfficeTransactionRepository officeTransactionRepository;
|
private final OfficeTransactionRepository officeTransactionRepository;
|
||||||
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
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
|
@Transactional
|
||||||
@Override
|
@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
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
||||||
|
|||||||
+2
-2
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
|
|||||||
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||||
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
||||||
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
||||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
|
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
|
||||||
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
||||||
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
||||||
applicationCurrencyRepository);
|
applicationCurrencyRepository, jdbcTemplate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -25,12 +25,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.HeaderParam;
|
import jakarta.ws.rs.HeaderParam;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@@ -234,11 +235,33 @@ public class ClientAllAccountsApiResource {
|
|||||||
return client;
|
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 "
|
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
|
||||||
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
|
+ "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) {
|
private String resolveClientIp(final HttpServletRequest request) {
|
||||||
for (final String header : IP_HEADER_CANDIDATES) {
|
for (final String header : IP_HEADER_CANDIDATES) {
|
||||||
final String value = request.getHeader(header);
|
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.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
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.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||||
@@ -384,6 +384,14 @@ server.forward-headers-strategy=framework
|
|||||||
server.port=${FINERACT_SERVER_PORT:8443}
|
server.port=${FINERACT_SERVER_PORT:8443}
|
||||||
server.servlet.context-path=${FINERACT_SERVER_SERVLET_CONTEXT_PATH:/fineract-provider}
|
server.servlet.context-path=${FINERACT_SERVER_SERVLET_CONTEXT_PATH:/fineract-provider}
|
||||||
server.compression.enabled=${FINERACT_SERVER_COMPRESSION_ENABLED:true}
|
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.enabled=${FINERACT_SERVER_SSL_ENABLED:true}
|
||||||
server.ssl.protocol=TLS
|
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.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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
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.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
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/0522_create_deaf_remittance.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0523_dbt_dormancy_exemption.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"/>
|
<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/0525_savings_daily_interest_job.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0526_savings_interest_summary_view.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"/>
|
<include file="parts/0527_savings_daily_interest_one_row_per_account.xml" relativeToChangelogFile="true"/>
|
||||||
|
|||||||
+12
-2
@@ -43,8 +43,16 @@
|
|||||||
<include file="parts/0517_grant_checker_roles_hold_release_amount.xml" relativeToChangelogFile="true"/>
|
<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/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/0516_seed_savings_interest_posting_control_gls.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
|
<!-- 0517_create_savings_daily_interest_ledger.xml and 0518_create_savings_interest_posting_schedule.xml
|
||||||
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
|
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/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/0520_drop_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0521_branch_day_register_vault_only_detail.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/0525_create_clerk_closure_request.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0526_seed_round_off_account.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/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>
|
</databaseChangeLog>
|
||||||
|
|||||||
+30
@@ -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>
|
||||||
+51
@@ -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>
|
||||||
Reference in New Issue
Block a user