diff --git a/.github/workflows/maven-build.yml b/.github/workflows/maven-build.yml
index bc0fc67..55055b6 100644
--- a/.github/workflows/maven-build.yml
+++ b/.github/workflows/maven-build.yml
@@ -80,6 +80,7 @@ jobs:
name: jacoco-it-exec
path: target/jacoco-it.exec
retention-days: 1
+ if-no-files-found: ignore
# ── Job 3: Coverage Gate — merges both .exec files before enforcing ───────
coverage-gate:
@@ -107,6 +108,7 @@ jobs:
- name: Download Integration Test Coverage Data
uses: actions/download-artifact@v8
+ continue-on-error: true
with:
name: jacoco-it-exec
path: target/
diff --git a/src/main/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResource.java b/src/main/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResource.java
new file mode 100644
index 0000000..6463f9b
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResource.java
@@ -0,0 +1,227 @@
+/**
+ * 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.savings.office.api;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.ArraySchema;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+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.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import java.util.Collection;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.savings.office.data.OfficeGeolocationData;
+import org.apache.fineract.savings.office.data.OfficeServiceData;
+import org.apache.fineract.savings.office.service.OfficeExtensionReadPlatformService;
+import org.apache.fineract.savings.office.service.OfficeExtensionWritePlatformService;
+import org.springframework.stereotype.Component;
+
+/**
+ * JAX-RS resource exposing admin CRUD endpoints for office services and geolocation under {@code
+ * /v2/offices/{officeId}/services} and {@code /v2/offices/{officeId}/geolocation}.
+ */
+@Path("/v2/offices")
+@Component
+@Tag(
+ name = "Office Extensions",
+ description =
+ "Admin endpoints for managing office services and geolocation data. These endpoints populate"
+ + " the data consumed by the self-service office endpoints in the selfservice-plugin.")
+@RequiredArgsConstructor
+public class OfficeExtensionApiResource {
+
+ private static final String RESOURCE_NAME_FOR_PERMISSIONS = "OFFICE";
+
+ private final PlatformSecurityContext context;
+ private final OfficeExtensionReadPlatformService readService;
+ private final OfficeExtensionWritePlatformService writeService;
+ private final DefaultToApiJsonSerializer serviceSerializer;
+ private final DefaultToApiJsonSerializer geolocationSerializer;
+
+ /**
+ * Lists all services associated with the given office.
+ *
+ * @param officeId the office identifier
+ * @return JSON array of office service data
+ */
+ @GET
+ @Path("{officeId}/services")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "List Office Services",
+ description = "Returns all services configured for the specified office.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ array = @ArraySchema(schema = @Schema(implementation = OfficeServiceData.class))))
+ public String retrieveOfficeServices(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final Collection data = readService.retrieveOfficeServices(officeId);
+ return serviceSerializer.serializeResult(data);
+ }
+
+ /**
+ * Creates a new service for the given office.
+ *
+ * @param officeId the office identifier
+ * @param jsonBody the JSON request body
+ * @return JSON with the created service id
+ */
+ @POST
+ @Path("{officeId}/services")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Create Office Service",
+ description = "Adds a new service to the specified office.")
+ @ApiResponse(responseCode = "200", description = "OK")
+ public String createOfficeService(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId,
+ @Parameter(hidden = true) final String jsonBody) {
+ this.context.authenticatedUser().validateHasCreatePermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final CommandProcessingResult result = writeService.createOfficeService(officeId, jsonBody);
+ return serviceSerializer.serializeResult(result);
+ }
+
+ /**
+ * Updates an existing office service.
+ *
+ * @param officeId the office identifier
+ * @param serviceId the service identifier
+ * @param jsonBody the JSON request body
+ * @return JSON with the changes applied
+ */
+ @PUT
+ @Path("{officeId}/services/{serviceId}")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Update Office Service",
+ description = "Updates a specific service for the office.")
+ @ApiResponse(responseCode = "200", description = "OK")
+ public String updateOfficeService(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId,
+ @PathParam("serviceId") @Parameter(description = "serviceId") final Long serviceId,
+ @Parameter(hidden = true) final String jsonBody) {
+ this.context.authenticatedUser().validateHasUpdatePermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final CommandProcessingResult result =
+ writeService.updateOfficeService(officeId, serviceId, jsonBody);
+ return serviceSerializer.serializeResult(result);
+ }
+
+ /**
+ * Deletes an office service.
+ *
+ * @param officeId the office identifier
+ * @param serviceId the service identifier
+ * @return JSON with the deleted service id
+ */
+ @DELETE
+ @Path("{officeId}/services/{serviceId}")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Delete Office Service",
+ description = "Removes a specific service from the office.")
+ @ApiResponse(responseCode = "200", description = "OK")
+ public String deleteOfficeService(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId,
+ @PathParam("serviceId") @Parameter(description = "serviceId") final Long serviceId) {
+ this.context.authenticatedUser().validateHasDeletePermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final CommandProcessingResult result = writeService.deleteOfficeService(officeId, serviceId);
+ return serviceSerializer.serializeResult(result);
+ }
+
+ /**
+ * Retrieves the geolocation for the given office.
+ *
+ * @param officeId the office identifier
+ * @return JSON representation of geolocation data
+ */
+ @GET
+ @Path("{officeId}/geolocation")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Retrieve Office Geolocation",
+ description = "Returns the latitude and longitude of the specified office.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content = @Content(schema = @Schema(implementation = OfficeGeolocationData.class)))
+ public String retrieveOfficeGeolocation(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final OfficeGeolocationData data = readService.retrieveOfficeGeolocation(officeId);
+ return geolocationSerializer.serializeResult(data);
+ }
+
+ /**
+ * Creates or updates the geolocation for the given office.
+ *
+ * @param officeId the office identifier
+ * @param jsonBody the JSON request body containing latitude and longitude
+ * @return JSON with the geolocation record id
+ */
+ @PUT
+ @Path("{officeId}/geolocation")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Save Office Geolocation",
+ description = "Creates or updates the geolocation for the office (1:1 relationship).")
+ @ApiResponse(responseCode = "200", description = "OK")
+ public String saveOfficeGeolocation(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId,
+ @Parameter(hidden = true) final String jsonBody) {
+ this.context.authenticatedUser().validateHasUpdatePermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final CommandProcessingResult result = writeService.saveOfficeGeolocation(officeId, jsonBody);
+ return geolocationSerializer.serializeResult(result);
+ }
+
+ /**
+ * Deletes the geolocation for the given office.
+ *
+ * @param officeId the office identifier
+ * @return JSON with the deleted office id
+ */
+ @DELETE
+ @Path("{officeId}/geolocation")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Delete Office Geolocation",
+ description = "Removes the geolocation data for the office.")
+ @ApiResponse(responseCode = "200", description = "OK")
+ public String deleteOfficeGeolocation(
+ @PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
+ this.context.authenticatedUser().validateHasDeletePermission(RESOURCE_NAME_FOR_PERMISSIONS);
+ final CommandProcessingResult result = writeService.deleteOfficeGeolocation(officeId);
+ return geolocationSerializer.serializeResult(result);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/data/OfficeGeolocationData.java b/src/main/java/org/apache/fineract/savings/office/data/OfficeGeolocationData.java
new file mode 100644
index 0000000..80dade2
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/data/OfficeGeolocationData.java
@@ -0,0 +1,84 @@
+/**
+ * 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.savings.office.data;
+
+import java.math.BigDecimal;
+
+/** Immutable data transfer object representing an office's geographic coordinates. */
+public final class OfficeGeolocationData {
+
+ private final Long id;
+ private final Long officeId;
+ private final BigDecimal latitude;
+ private final BigDecimal longitude;
+
+ private OfficeGeolocationData(
+ final Long id, final Long officeId, final BigDecimal latitude, final BigDecimal longitude) {
+ this.id = id;
+ this.officeId = officeId;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param id the geolocation record identifier
+ * @param officeId the parent office identifier
+ * @param latitude the latitude in decimal degrees (-90 to 90)
+ * @param longitude the longitude in decimal degrees (-180 to 180)
+ * @return a new {@code OfficeGeolocationData}
+ */
+ public static OfficeGeolocationData instance(
+ final Long id, final Long officeId, final BigDecimal latitude, final BigDecimal longitude) {
+ return new OfficeGeolocationData(id, officeId, latitude, longitude);
+ }
+
+ /**
+ * Returns the geolocation record identifier.
+ *
+ * @return the record id, never {@code null}
+ */
+ public Long getId() {
+ return id;
+ }
+
+ /**
+ * Returns the associated office identifier.
+ *
+ * @return the office id, never {@code null}
+ */
+ public Long getOfficeId() {
+ return officeId;
+ }
+
+ /**
+ * Returns the latitude in decimal degrees.
+ *
+ * @return the latitude value (-90 to 90), never {@code null}
+ */
+ public BigDecimal getLatitude() {
+ return latitude;
+ }
+
+ /**
+ * Returns the longitude in decimal degrees.
+ *
+ * @return the longitude value (-180 to 180), never {@code null}
+ */
+ public BigDecimal getLongitude() {
+ return longitude;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/data/OfficeServiceData.java b/src/main/java/org/apache/fineract/savings/office/data/OfficeServiceData.java
new file mode 100644
index 0000000..adfc9c1
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/data/OfficeServiceData.java
@@ -0,0 +1,102 @@
+/**
+ * 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.savings.office.data;
+
+/** Immutable data transfer object representing a service offered by an office. */
+public final class OfficeServiceData {
+
+ private final Long id;
+ private final Long officeId;
+ private final String serviceName;
+ private final String serviceExternalId;
+ private final String workingHours;
+
+ private OfficeServiceData(
+ final Long id,
+ final Long officeId,
+ final String serviceName,
+ final String serviceExternalId,
+ final String workingHours) {
+ this.id = id;
+ this.officeId = officeId;
+ this.serviceName = serviceName;
+ this.serviceExternalId = serviceExternalId;
+ this.workingHours = workingHours;
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param id the service identifier
+ * @param officeId the parent office identifier
+ * @param serviceName the human-readable service name
+ * @param serviceExternalId the external identifier for the service
+ * @param workingHours the working hours description
+ * @return a new {@code OfficeServiceData}
+ */
+ public static OfficeServiceData instance(
+ final Long id,
+ final Long officeId,
+ final String serviceName,
+ final String serviceExternalId,
+ final String workingHours) {
+ return new OfficeServiceData(id, officeId, serviceName, serviceExternalId, workingHours);
+ }
+
+ /**
+ * Returns the service identifier.
+ *
+ * @return the service id, never {@code null}
+ */
+ public Long getId() {
+ return id;
+ }
+
+ /**
+ * Returns the parent office identifier.
+ *
+ * @return the office id, never {@code null}
+ */
+ public Long getOfficeId() {
+ return officeId;
+ }
+
+ /**
+ * Returns the human-readable service name.
+ *
+ * @return the service name, may be {@code null}
+ */
+ public String getServiceName() {
+ return serviceName;
+ }
+
+ /**
+ * Returns the external identifier for the service.
+ *
+ * @return the external id, may be {@code null}
+ */
+ public String getServiceExternalId() {
+ return serviceExternalId;
+ }
+
+ /**
+ * Returns the working hours description.
+ *
+ * @return the working hours text, may be {@code null}
+ */
+ public String getWorkingHours() {
+ return workingHours;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformService.java b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformService.java
new file mode 100644
index 0000000..302bf44
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformService.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.savings.office.service;
+
+import java.util.Collection;
+import org.apache.fineract.savings.office.data.OfficeGeolocationData;
+import org.apache.fineract.savings.office.data.OfficeServiceData;
+
+/** Read-only platform service for retrieving office services and geolocation data. */
+public interface OfficeExtensionReadPlatformService {
+
+ /**
+ * Retrieves all services associated with the given office.
+ *
+ * @param officeId the office identifier
+ * @return a collection of services; empty if none are configured
+ */
+ Collection retrieveOfficeServices(Long officeId);
+
+ /**
+ * Retrieves a single office service by its identifier.
+ *
+ * @param serviceId the service identifier
+ * @return the service data
+ */
+ OfficeServiceData retrieveOfficeService(Long serviceId);
+
+ /**
+ * Retrieves the geolocation data for the given office.
+ *
+ * @param officeId the office identifier
+ * @return the geolocation data, or {@code null} if none exists
+ */
+ OfficeGeolocationData retrieveOfficeGeolocation(Long officeId);
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformServiceImpl.java b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformServiceImpl.java
new file mode 100644
index 0000000..ba774cb
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionReadPlatformServiceImpl.java
@@ -0,0 +1,106 @@
+/**
+ * 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.savings.office.service;
+
+import java.math.BigDecimal;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Collection;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.savings.office.data.OfficeGeolocationData;
+import org.apache.fineract.savings.office.data.OfficeServiceData;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/** JDBC-backed implementation of {@link OfficeExtensionReadPlatformService}. */
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class OfficeExtensionReadPlatformServiceImpl implements OfficeExtensionReadPlatformService {
+
+ private final JdbcTemplate jdbcTemplate;
+ private final PlatformSecurityContext context;
+
+ /** {@inheritDoc} */
+ @Override
+ public Collection retrieveOfficeServices(final Long officeId) {
+ this.context.authenticatedUser();
+ return this.jdbcTemplate.query(
+ "SELECT s.id, s.office_id, s.service_name, s.service_external_id, s.working_hours"
+ + " FROM m_selfservice_office_service s WHERE s.office_id = ?",
+ new OfficeServiceRowMapper(),
+ officeId);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public OfficeServiceData retrieveOfficeService(final Long serviceId) {
+ this.context.authenticatedUser();
+ try {
+ return this.jdbcTemplate.queryForObject(
+ "SELECT s.id, s.office_id, s.service_name, s.service_external_id, s.working_hours"
+ + " FROM m_selfservice_office_service s WHERE s.id = ?",
+ new OfficeServiceRowMapper(),
+ serviceId);
+ } catch (final EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public OfficeGeolocationData retrieveOfficeGeolocation(final Long officeId) {
+ this.context.authenticatedUser();
+ try {
+ return this.jdbcTemplate.queryForObject(
+ "SELECT g.id, g.office_id, g.latitude, g.longitude"
+ + " FROM m_selfservice_office_geolocation g WHERE g.office_id = ?",
+ new OfficeGeolocationRowMapper(),
+ officeId);
+ } catch (final EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+
+ private static final class OfficeServiceRowMapper implements RowMapper {
+
+ @Override
+ public OfficeServiceData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
+ final Long id = rs.getLong("id");
+ final Long officeId = rs.getLong("office_id");
+ final String serviceName = rs.getString("service_name");
+ final String serviceExternalId = rs.getString("service_external_id");
+ final String workingHours = rs.getString("working_hours");
+ return OfficeServiceData.instance(id, officeId, serviceName, serviceExternalId, workingHours);
+ }
+ }
+
+ private static final class OfficeGeolocationRowMapper
+ implements RowMapper {
+
+ @Override
+ public OfficeGeolocationData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
+ final Long id = rs.getLong("id");
+ final Long officeId = rs.getLong("office_id");
+ final BigDecimal latitude = rs.getBigDecimal("latitude");
+ final BigDecimal longitude = rs.getBigDecimal("longitude");
+ return OfficeGeolocationData.instance(id, officeId, latitude, longitude);
+ }
+ }
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformService.java b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformService.java
new file mode 100644
index 0000000..5da2174
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformService.java
@@ -0,0 +1,66 @@
+/**
+ * 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.savings.office.service;
+
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+
+/** Write platform service for managing office services and geolocation data. */
+public interface OfficeExtensionWritePlatformService {
+
+ /**
+ * Creates a new service for an office.
+ *
+ * @param officeId the parent office identifier
+ * @param jsonBody the JSON request body
+ * @return the result containing the new service id
+ */
+ CommandProcessingResult createOfficeService(Long officeId, String jsonBody);
+
+ /**
+ * Updates an existing office service, scoped to the given office.
+ *
+ * @param officeId the parent office identifier (used to scope the update)
+ * @param serviceId the service identifier
+ * @param jsonBody the JSON request body
+ * @return the result containing the updated service id
+ */
+ CommandProcessingResult updateOfficeService(Long officeId, Long serviceId, String jsonBody);
+
+ /**
+ * Deletes an office service, scoped to the given office.
+ *
+ * @param officeId the parent office identifier (used to scope the delete)
+ * @param serviceId the service identifier
+ * @return the result containing the deleted service id
+ */
+ CommandProcessingResult deleteOfficeService(Long officeId, Long serviceId);
+
+ /**
+ * Creates or updates the geolocation for an office (1:1 relationship).
+ *
+ * @param officeId the office identifier
+ * @param jsonBody the JSON request body
+ * @return the result containing the geolocation record id
+ */
+ CommandProcessingResult saveOfficeGeolocation(Long officeId, String jsonBody);
+
+ /**
+ * Deletes the geolocation for an office.
+ *
+ * @param officeId the office identifier
+ * @return the result containing the deleted record id
+ */
+ CommandProcessingResult deleteOfficeGeolocation(Long officeId);
+}
diff --git a/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImpl.java b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImpl.java
new file mode 100644
index 0000000..e9d326a
--- /dev/null
+++ b/src/main/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImpl.java
@@ -0,0 +1,267 @@
+/**
+ * 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.savings.office.service;
+
+import com.google.gson.JsonElement;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.data.ApiParameterError;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
+import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
+import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.organisation.office.exception.OfficeNotFoundException;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * JDBC-backed implementation of {@link OfficeExtensionWritePlatformService} for managing office
+ * services and geolocation data.
+ */
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class OfficeExtensionWritePlatformServiceImpl
+ implements OfficeExtensionWritePlatformService {
+
+ private static final String RESOURCE_NAME = "officeService";
+ private static final BigDecimal LAT_MIN = new BigDecimal("-90");
+ private static final BigDecimal LAT_MAX = new BigDecimal("90");
+ private static final BigDecimal LNG_MIN = new BigDecimal("-180");
+ private static final BigDecimal LNG_MAX = new BigDecimal("180");
+
+ private final JdbcTemplate jdbcTemplate;
+ private final NamedParameterJdbcTemplate namedJdbcTemplate;
+ private final PlatformSecurityContext context;
+ private final FromJsonHelper fromJsonHelper;
+
+ /** {@inheritDoc} */
+ @Override
+ public CommandProcessingResult createOfficeService(final Long officeId, final String jsonBody) {
+ this.context.authenticatedUser();
+ validateOfficeExists(officeId);
+
+ final JsonElement element = this.fromJsonHelper.parse(jsonBody);
+ final String serviceName = this.fromJsonHelper.extractStringNamed("serviceName", element);
+ final String serviceExternalId =
+ this.fromJsonHelper.extractStringNamed("serviceExternalId", element);
+ final String workingHours = this.fromJsonHelper.extractStringNamed("workingHours", element);
+
+ final MapSqlParameterSource params = new MapSqlParameterSource();
+ params.addValue("officeId", officeId);
+ params.addValue("serviceName", serviceName);
+ params.addValue("serviceExternalId", serviceExternalId);
+ params.addValue("workingHours", workingHours);
+
+ final KeyHolder keyHolder = new GeneratedKeyHolder();
+ this.namedJdbcTemplate.update(
+ "INSERT INTO m_selfservice_office_service (office_id, service_name, service_external_id, working_hours)"
+ + " VALUES (:officeId, :serviceName, :serviceExternalId, :workingHours)",
+ params,
+ keyHolder,
+ new String[] {"id"});
+
+ final Long serviceId = keyHolder.getKey().longValue();
+ return new CommandProcessingResultBuilder()
+ .withEntityId(serviceId)
+ .withOfficeId(officeId)
+ .build();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CommandProcessingResult updateOfficeService(
+ final Long officeId, final Long serviceId, final String jsonBody) {
+ this.context.authenticatedUser();
+
+ final JsonElement element = this.fromJsonHelper.parse(jsonBody);
+ final Map changes = new HashMap<>();
+
+ final StringBuilder sql = new StringBuilder("UPDATE m_selfservice_office_service SET ");
+ final MapSqlParameterSource params = new MapSqlParameterSource();
+ params.addValue("serviceId", serviceId);
+ params.addValue("officeId", officeId);
+ boolean first = true;
+
+ if (this.fromJsonHelper.parameterExists("serviceName", element)) {
+ final String serviceName = this.fromJsonHelper.extractStringNamed("serviceName", element);
+ sql.append("service_name = :serviceName");
+ params.addValue("serviceName", serviceName);
+ changes.put("serviceName", serviceName);
+ first = false;
+ }
+ if (this.fromJsonHelper.parameterExists("serviceExternalId", element)) {
+ if (!first) {
+ sql.append(", ");
+ }
+ final String serviceExternalId =
+ this.fromJsonHelper.extractStringNamed("serviceExternalId", element);
+ sql.append("service_external_id = :serviceExternalId");
+ params.addValue("serviceExternalId", serviceExternalId);
+ changes.put("serviceExternalId", serviceExternalId);
+ first = false;
+ }
+ if (this.fromJsonHelper.parameterExists("workingHours", element)) {
+ if (!first) {
+ sql.append(", ");
+ }
+ final String workingHours = this.fromJsonHelper.extractStringNamed("workingHours", element);
+ sql.append("working_hours = :workingHours");
+ params.addValue("workingHours", workingHours);
+ changes.put("workingHours", workingHours);
+ }
+ sql.append(" WHERE id = :serviceId AND office_id = :officeId");
+
+ if (!changes.isEmpty()) {
+ final int affectedRows = this.namedJdbcTemplate.update(sql.toString(), params);
+ if (affectedRows == 0) {
+ throw new PlatformDataIntegrityException(
+ "error.msg.office.service.not.found",
+ "Office service with id " + serviceId + " not found for office " + officeId,
+ "serviceId",
+ serviceId);
+ }
+ }
+
+ return new CommandProcessingResultBuilder()
+ .withEntityId(serviceId)
+ .withOfficeId(officeId)
+ .with(changes)
+ .build();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CommandProcessingResult deleteOfficeService(final Long officeId, final Long serviceId) {
+ this.context.authenticatedUser();
+ final int affectedRows =
+ this.jdbcTemplate.update(
+ "DELETE FROM m_selfservice_office_service WHERE id = ? AND office_id = ?",
+ serviceId,
+ officeId);
+ if (affectedRows == 0) {
+ throw new PlatformDataIntegrityException(
+ "error.msg.office.service.not.found",
+ "Office service with id " + serviceId + " not found for office " + officeId,
+ "serviceId",
+ serviceId);
+ }
+ return new CommandProcessingResultBuilder()
+ .withEntityId(serviceId)
+ .withOfficeId(officeId)
+ .build();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CommandProcessingResult saveOfficeGeolocation(final Long officeId, final String jsonBody) {
+ this.context.authenticatedUser();
+ validateOfficeExists(officeId);
+
+ final JsonElement element = this.fromJsonHelper.parse(jsonBody);
+ final BigDecimal latitude =
+ this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", element);
+ final BigDecimal longitude =
+ this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", element);
+
+ validateCoordinates(latitude, longitude);
+
+ final MapSqlParameterSource params = new MapSqlParameterSource();
+ params.addValue("officeId", officeId);
+ params.addValue("latitude", latitude);
+ params.addValue("longitude", longitude);
+
+ this.namedJdbcTemplate.update(
+ "INSERT INTO m_selfservice_office_geolocation (office_id, latitude, longitude)"
+ + " VALUES (:officeId, :latitude, :longitude)"
+ + " ON CONFLICT (office_id) DO UPDATE SET latitude = EXCLUDED.latitude,"
+ + " longitude = EXCLUDED.longitude",
+ params);
+
+ final Long geolocationId =
+ this.jdbcTemplate.queryForObject(
+ "SELECT id FROM m_selfservice_office_geolocation WHERE office_id = ?",
+ Long.class,
+ officeId);
+
+ return new CommandProcessingResultBuilder()
+ .withEntityId(geolocationId)
+ .withOfficeId(officeId)
+ .build();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public CommandProcessingResult deleteOfficeGeolocation(final Long officeId) {
+ this.context.authenticatedUser();
+ this.jdbcTemplate.update(
+ "DELETE FROM m_selfservice_office_geolocation WHERE office_id = ?", officeId);
+ return new CommandProcessingResultBuilder().withOfficeId(officeId).build();
+ }
+
+ private void validateOfficeExists(final Long officeId) {
+ try {
+ this.jdbcTemplate.queryForObject(
+ "SELECT 1 FROM m_office WHERE id = ?", Integer.class, officeId);
+ } catch (final EmptyResultDataAccessException e) {
+ throw new OfficeNotFoundException(officeId, e);
+ }
+ }
+
+ private void validateCoordinates(final BigDecimal latitude, final BigDecimal longitude) {
+ final List errors = new ArrayList<>();
+ final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE_NAME);
+
+ validator.parameter("latitude").value(latitude).notNull();
+ validator.parameter("longitude").value(longitude).notNull();
+
+ if (!errors.isEmpty()) {
+ throw new PlatformApiDataValidationException(errors);
+ }
+
+ if (latitude.compareTo(LAT_MIN) < 0 || latitude.compareTo(LAT_MAX) > 0) {
+ errors.add(
+ ApiParameterError.parameterError(
+ "validation.msg.office.geolocation.latitude.out.of.range",
+ "Latitude must be between -90 and 90",
+ "latitude",
+ latitude));
+ }
+ if (longitude.compareTo(LNG_MIN) < 0 || longitude.compareTo(LNG_MAX) > 0) {
+ errors.add(
+ ApiParameterError.parameterError(
+ "validation.msg.office.geolocation.longitude.out.of.range",
+ "Longitude must be between -180 and 180",
+ "longitude",
+ longitude));
+ }
+ if (!errors.isEmpty()) {
+ throw new PlatformApiDataValidationException(errors);
+ }
+ }
+}
diff --git a/src/test/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResourceTest.java b/src/test/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResourceTest.java
new file mode 100644
index 0000000..a04847d
--- /dev/null
+++ b/src/test/java/org/apache/fineract/savings/office/api/OfficeExtensionApiResourceTest.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.savings.office.api;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.List;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
+import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.savings.office.data.OfficeGeolocationData;
+import org.apache.fineract.savings.office.data.OfficeServiceData;
+import org.apache.fineract.savings.office.service.OfficeExtensionReadPlatformService;
+import org.apache.fineract.savings.office.service.OfficeExtensionWritePlatformService;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class OfficeExtensionApiResourceTest {
+
+ @Mock private PlatformSecurityContext context;
+ @Mock private OfficeExtensionReadPlatformService readService;
+ @Mock private OfficeExtensionWritePlatformService writeService;
+ @Mock private DefaultToApiJsonSerializer serviceSerializer;
+ @Mock private DefaultToApiJsonSerializer geolocationSerializer;
+
+ private OfficeExtensionApiResource resource;
+
+ private static final Long OFFICE_ID = 1L;
+ private static final Long SERVICE_ID = 10L;
+
+ @BeforeEach
+ void setUp() {
+ resource =
+ new OfficeExtensionApiResource(
+ context, readService, writeService, serviceSerializer, geolocationSerializer);
+ }
+
+ private void mockAuthenticatedUser() {
+ AppUser user = mock(AppUser.class);
+ when(context.authenticatedUser()).thenReturn(user);
+ }
+
+ @Test
+ void retrieveOfficeServices_returnsData() {
+ mockAuthenticatedUser();
+ List data =
+ List.of(
+ OfficeServiceData.instance(
+ SERVICE_ID, OFFICE_ID, "Account Opening", "SVC-001", "Mon-Fri 09:00-17:00"));
+ when(readService.retrieveOfficeServices(OFFICE_ID)).thenReturn(data);
+ when(serviceSerializer.serializeResult(data)).thenReturn("[]");
+
+ String result = resource.retrieveOfficeServices(OFFICE_ID);
+
+ assertNotNull(result);
+ verify(readService).retrieveOfficeServices(OFFICE_ID);
+ }
+
+ @Test
+ void createOfficeService_delegatesToWriteService() {
+ mockAuthenticatedUser();
+ CommandProcessingResult cpr =
+ new CommandProcessingResultBuilder().withEntityId(SERVICE_ID).build();
+ String jsonBody = "{\"serviceName\":\"Loans\",\"workingHours\":\"Mon-Fri\"}";
+ when(writeService.createOfficeService(OFFICE_ID, jsonBody)).thenReturn(cpr);
+ when(serviceSerializer.serializeResult(cpr)).thenReturn("{}");
+
+ String result = resource.createOfficeService(OFFICE_ID, jsonBody);
+
+ assertNotNull(result);
+ verify(writeService).createOfficeService(OFFICE_ID, jsonBody);
+ }
+
+ @Test
+ void updateOfficeService_passesOfficeIdToWriteService() {
+ mockAuthenticatedUser();
+ CommandProcessingResult cpr =
+ new CommandProcessingResultBuilder().withEntityId(SERVICE_ID).build();
+ String jsonBody = "{\"serviceName\":\"Updated\"}";
+ when(writeService.updateOfficeService(OFFICE_ID, SERVICE_ID, jsonBody)).thenReturn(cpr);
+ when(serviceSerializer.serializeResult(cpr)).thenReturn("{}");
+
+ String result = resource.updateOfficeService(OFFICE_ID, SERVICE_ID, jsonBody);
+
+ assertNotNull(result);
+ verify(writeService).updateOfficeService(OFFICE_ID, SERVICE_ID, jsonBody);
+ }
+
+ @Test
+ void deleteOfficeService_passesOfficeIdToWriteService() {
+ mockAuthenticatedUser();
+ CommandProcessingResult cpr =
+ new CommandProcessingResultBuilder().withEntityId(SERVICE_ID).build();
+ when(writeService.deleteOfficeService(OFFICE_ID, SERVICE_ID)).thenReturn(cpr);
+ when(serviceSerializer.serializeResult(cpr)).thenReturn("{}");
+
+ String result = resource.deleteOfficeService(OFFICE_ID, SERVICE_ID);
+
+ assertNotNull(result);
+ verify(writeService).deleteOfficeService(OFFICE_ID, SERVICE_ID);
+ }
+
+ @Test
+ void retrieveOfficeGeolocation_returnsData() {
+ mockAuthenticatedUser();
+ OfficeGeolocationData data =
+ OfficeGeolocationData.instance(
+ 1L, OFFICE_ID, new BigDecimal("19.4326077"), new BigDecimal("-99.1332080"));
+ when(readService.retrieveOfficeGeolocation(OFFICE_ID)).thenReturn(data);
+ when(geolocationSerializer.serializeResult(data)).thenReturn("{}");
+
+ String result = resource.retrieveOfficeGeolocation(OFFICE_ID);
+
+ assertNotNull(result);
+ verify(readService).retrieveOfficeGeolocation(OFFICE_ID);
+ }
+
+ @Test
+ void saveOfficeGeolocation_delegatesToWriteService() {
+ mockAuthenticatedUser();
+ CommandProcessingResult cpr =
+ new CommandProcessingResultBuilder().withEntityId(1L).withOfficeId(OFFICE_ID).build();
+ String jsonBody = "{\"latitude\":\"19.43\",\"longitude\":\"-99.13\",\"locale\":\"en\"}";
+ when(writeService.saveOfficeGeolocation(OFFICE_ID, jsonBody)).thenReturn(cpr);
+ when(geolocationSerializer.serializeResult(cpr)).thenReturn("{}");
+
+ String result = resource.saveOfficeGeolocation(OFFICE_ID, jsonBody);
+
+ assertNotNull(result);
+ verify(writeService).saveOfficeGeolocation(OFFICE_ID, jsonBody);
+ }
+
+ @Test
+ void deleteOfficeGeolocation_delegatesToWriteService() {
+ mockAuthenticatedUser();
+ CommandProcessingResult cpr =
+ new CommandProcessingResultBuilder().withOfficeId(OFFICE_ID).build();
+ when(writeService.deleteOfficeGeolocation(OFFICE_ID)).thenReturn(cpr);
+ when(geolocationSerializer.serializeResult(any())).thenReturn("{}");
+
+ String result = resource.deleteOfficeGeolocation(OFFICE_ID);
+
+ assertNotNull(result);
+ verify(writeService).deleteOfficeGeolocation(OFFICE_ID);
+ }
+}
diff --git a/src/test/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImplTest.java b/src/test/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImplTest.java
new file mode 100644
index 0000000..14deb76
--- /dev/null
+++ b/src/test/java/org/apache/fineract/savings/office/service/OfficeExtensionWritePlatformServiceImplTest.java
@@ -0,0 +1,269 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.savings.office.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.apache.fineract.organisation.office.exception.OfficeNotFoundException;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+
+@ExtendWith(MockitoExtension.class)
+class OfficeExtensionWritePlatformServiceImplTest {
+
+ @Mock private JdbcTemplate jdbcTemplate;
+ @Mock private NamedParameterJdbcTemplate namedJdbcTemplate;
+ @Mock private PlatformSecurityContext context;
+ @Mock private FromJsonHelper fromJsonHelper;
+
+ private OfficeExtensionWritePlatformServiceImpl service;
+
+ private static final Long OFFICE_ID = 1L;
+ private static final Long SERVICE_ID = 10L;
+
+ @BeforeEach
+ void setUp() {
+ service =
+ new OfficeExtensionWritePlatformServiceImpl(
+ jdbcTemplate, namedJdbcTemplate, context, fromJsonHelper);
+ }
+
+ private void mockAuth() {
+ AppUser user = mock(AppUser.class);
+ when(context.authenticatedUser()).thenReturn(user);
+ }
+
+ @Test
+ void createOfficeService_validOffice_returnsResult() {
+ mockAuth();
+ when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq(OFFICE_ID))).thenReturn(1);
+
+ String json = "{\"serviceName\":\"Loans\",\"workingHours\":\"Mon-Fri\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.extractStringNamed("serviceName", element)).thenReturn("Loans");
+ when(fromJsonHelper.extractStringNamed("serviceExternalId", element)).thenReturn(null);
+ when(fromJsonHelper.extractStringNamed("workingHours", element)).thenReturn("Mon-Fri");
+ when(namedJdbcTemplate.update(
+ anyString(),
+ any(MapSqlParameterSource.class),
+ any(org.springframework.jdbc.support.GeneratedKeyHolder.class),
+ any(String[].class)))
+ .thenAnswer(
+ invocation -> {
+ org.springframework.jdbc.support.GeneratedKeyHolder kh = invocation.getArgument(2);
+ var keyMap = new java.util.HashMap();
+ keyMap.put("id", 42L);
+ kh.getKeyList().add(keyMap);
+ return 1;
+ });
+
+ CommandProcessingResult result = service.createOfficeService(OFFICE_ID, json);
+
+ assertNotNull(result);
+ assertEquals(42L, result.getResourceId());
+ }
+
+ @Test
+ void createOfficeService_nonExistentOffice_throws() {
+ mockAuth();
+ when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq(999L)))
+ .thenThrow(new EmptyResultDataAccessException(1));
+
+ assertThrows(OfficeNotFoundException.class, () -> service.createOfficeService(999L, "{}"));
+ }
+
+ @Test
+ void updateOfficeService_withServiceName_updatesAndReturnsChanges() {
+ mockAuth();
+
+ String json = "{\"serviceName\":\"Updated\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.parameterExists("serviceName", element)).thenReturn(true);
+ when(fromJsonHelper.extractStringNamed("serviceName", element)).thenReturn("Updated");
+ when(fromJsonHelper.parameterExists("serviceExternalId", element)).thenReturn(false);
+ when(fromJsonHelper.parameterExists("workingHours", element)).thenReturn(false);
+ when(namedJdbcTemplate.update(anyString(), any(MapSqlParameterSource.class))).thenReturn(1);
+
+ CommandProcessingResult result = service.updateOfficeService(OFFICE_ID, SERVICE_ID, json);
+
+ assertNotNull(result);
+ verify(namedJdbcTemplate).update(anyString(), any(MapSqlParameterSource.class));
+ }
+
+ @Test
+ void updateOfficeService_withAllFields_updatesAll() {
+ mockAuth();
+
+ String json = "{\"serviceName\":\"A\",\"serviceExternalId\":\"B\",\"workingHours\":\"C\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.parameterExists("serviceName", element)).thenReturn(true);
+ when(fromJsonHelper.extractStringNamed("serviceName", element)).thenReturn("A");
+ when(fromJsonHelper.parameterExists("serviceExternalId", element)).thenReturn(true);
+ when(fromJsonHelper.extractStringNamed("serviceExternalId", element)).thenReturn("B");
+ when(fromJsonHelper.parameterExists("workingHours", element)).thenReturn(true);
+ when(fromJsonHelper.extractStringNamed("workingHours", element)).thenReturn("C");
+ when(namedJdbcTemplate.update(anyString(), any(MapSqlParameterSource.class))).thenReturn(1);
+
+ CommandProcessingResult result = service.updateOfficeService(OFFICE_ID, SERVICE_ID, json);
+
+ assertNotNull(result);
+ assertEquals(3, result.getChanges().size());
+ }
+
+ @Test
+ void updateOfficeService_zeroAffectedRows_throws() {
+ mockAuth();
+
+ String json = "{\"serviceName\":\"Ghost\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.parameterExists("serviceName", element)).thenReturn(true);
+ when(fromJsonHelper.extractStringNamed("serviceName", element)).thenReturn("Ghost");
+ when(fromJsonHelper.parameterExists("serviceExternalId", element)).thenReturn(false);
+ when(fromJsonHelper.parameterExists("workingHours", element)).thenReturn(false);
+ when(namedJdbcTemplate.update(anyString(), any(MapSqlParameterSource.class))).thenReturn(0);
+
+ assertThrows(
+ PlatformDataIntegrityException.class,
+ () -> service.updateOfficeService(OFFICE_ID, SERVICE_ID, json));
+ }
+
+ @Test
+ void updateOfficeService_noChanges_skipsUpdate() {
+ mockAuth();
+
+ String json = "{}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.parameterExists("serviceName", element)).thenReturn(false);
+ when(fromJsonHelper.parameterExists("serviceExternalId", element)).thenReturn(false);
+ when(fromJsonHelper.parameterExists("workingHours", element)).thenReturn(false);
+
+ CommandProcessingResult result = service.updateOfficeService(OFFICE_ID, SERVICE_ID, json);
+
+ assertNotNull(result);
+ verify(namedJdbcTemplate, never()).update(anyString(), any(MapSqlParameterSource.class));
+ }
+
+ @Test
+ void deleteOfficeService_existingService_succeeds() {
+ mockAuth();
+ when(jdbcTemplate.update(anyString(), eq(SERVICE_ID), eq(OFFICE_ID))).thenReturn(1);
+
+ CommandProcessingResult result = service.deleteOfficeService(OFFICE_ID, SERVICE_ID);
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void deleteOfficeService_nonExistent_throws() {
+ mockAuth();
+ when(jdbcTemplate.update(anyString(), eq(SERVICE_ID), eq(OFFICE_ID))).thenReturn(0);
+
+ assertThrows(
+ PlatformDataIntegrityException.class,
+ () -> service.deleteOfficeService(OFFICE_ID, SERVICE_ID));
+ }
+
+ @Test
+ void saveOfficeGeolocation_validCoordinates_succeeds() {
+ mockAuth();
+ when(jdbcTemplate.queryForObject(
+ eq("SELECT 1 FROM m_office WHERE id = ?"), eq(Integer.class), eq(OFFICE_ID)))
+ .thenReturn(1);
+ when(jdbcTemplate.queryForObject(
+ eq("SELECT id FROM m_selfservice_office_geolocation WHERE office_id = ?"),
+ eq(Long.class),
+ eq(OFFICE_ID)))
+ .thenReturn(5L);
+
+ String json = "{\"latitude\":\"19.43\",\"longitude\":\"-99.13\",\"locale\":\"en\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", element))
+ .thenReturn(new java.math.BigDecimal("19.43"));
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", element))
+ .thenReturn(new java.math.BigDecimal("-99.13"));
+
+ CommandProcessingResult result = service.saveOfficeGeolocation(OFFICE_ID, json);
+
+ assertNotNull(result);
+ assertEquals(5L, result.getResourceId());
+ }
+
+ @Test
+ void saveOfficeGeolocation_nullLatitude_throws() {
+ mockAuth();
+ when(jdbcTemplate.queryForObject(
+ eq("SELECT 1 FROM m_office WHERE id = ?"), eq(Integer.class), eq(OFFICE_ID)))
+ .thenReturn(1);
+
+ String json = "{\"longitude\":\"-99.13\",\"locale\":\"en\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", element)).thenReturn(null);
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", element))
+ .thenReturn(new java.math.BigDecimal("-99.13"));
+
+ assertThrows(
+ PlatformApiDataValidationException.class,
+ () -> service.saveOfficeGeolocation(OFFICE_ID, json));
+ }
+
+ @Test
+ void saveOfficeGeolocation_outOfRangeLatitude_throws() {
+ mockAuth();
+ when(jdbcTemplate.queryForObject(
+ eq("SELECT 1 FROM m_office WHERE id = ?"), eq(Integer.class), eq(OFFICE_ID)))
+ .thenReturn(1);
+
+ String json = "{\"latitude\":\"95.0\",\"longitude\":\"-99.13\",\"locale\":\"en\"}";
+ var element = new com.google.gson.JsonParser().parse(json);
+ when(fromJsonHelper.parse(json)).thenReturn(element);
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", element))
+ .thenReturn(new java.math.BigDecimal("95.0"));
+ when(fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", element))
+ .thenReturn(new java.math.BigDecimal("-99.13"));
+
+ assertThrows(
+ PlatformApiDataValidationException.class,
+ () -> service.saveOfficeGeolocation(OFFICE_ID, json));
+ }
+
+ @Test
+ void deleteOfficeGeolocation_delegatesToJdbc() {
+ mockAuth();
+ service.deleteOfficeGeolocation(OFFICE_ID);
+ verify(jdbcTemplate)
+ .update("DELETE FROM m_selfservice_office_geolocation WHERE office_id = ?", OFFICE_ID);
+ }
+}