Skip to content

Commit 991caa5

Browse files
committed
Fineract-2460: Add Client Performance API for real-time financial metrics
1 parent 5408921 commit 991caa5

File tree

7 files changed

+216
-23
lines changed

7 files changed

+216
-23
lines changed

fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientsApiResource.java

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import org.apache.fineract.portfolio.accountdetails.data.AccountSummaryCollectionData;
6767
import org.apache.fineract.portfolio.accountdetails.service.AccountDetailsReadPlatformService;
6868
import org.apache.fineract.portfolio.client.data.ClientData;
69+
import org.apache.fineract.portfolio.client.data.ClientPerformanceData;
6970
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
7071
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
7172
import org.apache.fineract.portfolio.client.service.ClientTemplateReadPlatformService;
@@ -104,8 +105,7 @@ public class ClientsApiResource {
104105
@Produces({ MediaType.APPLICATION_JSON })
105106
@Operation(summary = "Retrieve Client Details Template", description = "This is a convenience resource. It can be useful when building maintenance user interface screens for client applications. The template data returned consists of any or all of:\n"
106107
+ "\n" + "Field Defaults\n" + "Allowed Value Lists\n\n" + "Example Request:\n" + "\n" + "clients/template")
107-
@ApiResponses({
108-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsTemplateResponse.class))) })
108+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsTemplateResponse.class)))
109109
public String retrieveTemplate(@Context final UriInfo uriInfo,
110110
@Parameter(description = "officeId") @QueryParam("officeId") final Long officeId,
111111
@QueryParam("commandParam") @Parameter(description = "commandParam") final String commandParam,
@@ -137,8 +137,7 @@ public String retrieveTemplate(@Context final UriInfo uriInfo,
137137
@Operation(summary = "List Clients", description = "The list capability of clients can support pagination and sorting.\n\n"
138138
+ "Example Requests:\n" + "\n" + "clients\n" + "\n" + "clients?fields=displayName,officeName,timeline\n" + "\n"
139139
+ "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC")
140-
@ApiResponses({
141-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsResponse.class))) })
140+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsResponse.class)))
142141
public String retrieveAll(@Context final UriInfo uriInfo,
143142
@QueryParam("officeId") @Parameter(description = "officeId") final Long officeId,
144143
@QueryParam("externalId") @Parameter(description = "externalId") final String externalId,
@@ -164,14 +163,29 @@ public String retrieveAll(@Context final UriInfo uriInfo,
164163
@Produces({ MediaType.APPLICATION_JSON })
165164
@Operation(summary = "Retrieve a Client", description = "Example Requests:\n" + "\n" + "clients/1\n" + "\n" + "\n"
166165
+ "clients/1?template=true\n" + "\n" + "\n" + "clients/1?fields=id,displayName,officeName")
167-
@ApiResponses({
168-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsClientIdResponse.class))) })
166+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsClientIdResponse.class)))
169167
public String retrieveOne(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
170168
@Context final UriInfo uriInfo,
171169
@DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") @Parameter(description = "staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly) {
172170
return retrieveClient(clientId, null, staffInSelectedOfficeOnly, uriInfo);
173171
}
174172

173+
@GET
174+
@Path("{clientId}/performance")
175+
@Consumes({ MediaType.APPLICATION_JSON })
176+
@Produces({ MediaType.APPLICATION_JSON })
177+
@Operation(summary = "Retrieve client performance metrics", description = "Returns a summary of active loans and outstanding balances for a specific client.")
178+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientPerformanceData.class)))
179+
public String retrieveClientPerformance(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
180+
@Context final UriInfo uriInfo) {
181+
182+
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
183+
184+
final ClientPerformanceData performanceData = this.clientReadPlatformService.retrieveClientPerformance(clientId);
185+
186+
return this.toApiJsonSerializer.serialize(performanceData);
187+
}
188+
175189
@POST
176190
@Consumes({ MediaType.APPLICATION_JSON })
177191
@Produces({ MediaType.APPLICATION_JSON })
@@ -181,8 +195,7 @@ public String retrieveOne(@PathParam("clientId") @Parameter(description = "clien
181195
+ "Mandatory Fields: firstname and lastname OR fullname, officeId, active=true and activationDate OR active=false, if(address enabled) address\n\n"
182196
+ "Optional Fields: groupId, externalId, accountNo, staffId, mobileNo, savingsProductId, genderId, clientTypeId, clientClassificationId")
183197
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsRequest.class)))
184-
@ApiResponses({
185-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsResponse.class))) })
198+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsResponse.class)))
186199
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
187200

188201
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
@@ -204,8 +217,7 @@ public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson
204217
+ "Changing the relationship between a client and its office is not supported through this API. An API specific to handling transfers of clients between offices is available for the same.\n"
205218
+ "\n" + "The relationship between a client and a group must be removed through the Groups API.")
206219
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdRequest.class)))
207-
@ApiResponses({
208-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdResponse.class))) })
220+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdResponse.class)))
209221
public String update(@Parameter(description = "clientId") @PathParam("clientId") final Long clientId,
210222
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
211223
return updateClient(clientId, null, apiRequestBodyAsJson);
@@ -216,8 +228,7 @@ public String update(@Parameter(description = "clientId") @PathParam("clientId")
216228
@Consumes({ MediaType.APPLICATION_JSON })
217229
@Produces({ MediaType.APPLICATION_JSON })
218230
@Operation(summary = "Delete a Client", description = "If a client is in Pending state, you are allowed to Delete it. The delete is a 'hard delete' and cannot be recovered from. Once clients become active or have loans or savings associated with them, you cannot delete the client but you may Close the client if they have left the program.")
219-
@ApiResponses({
220-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.DeleteClientsClientIdResponse.class))) })
231+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.DeleteClientsClientIdResponse.class)))
221232
public String delete(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
222233
return deleteClient(clientId, null);
223234
}
@@ -257,8 +268,7 @@ public String delete(@PathParam("clientId") @Parameter(description = "clientId")
257268
+ "Abstraction over the Propose and Accept Client Transfer API's which enable a user with Data Scope over both the Target and Destination Branches to directly transfer a Client to the destination Office.\n\n"
258269
+ "Showing request/response for 'Reject a Client Transfer'")
259270
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdRequest.class)))
260-
@ApiResponses({
261-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdResponse.class))) })
271+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdResponse.class)))
262272
public String activate(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
263273
@QueryParam("command") @Parameter(description = "command") final String commandParam,
264274
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
@@ -328,8 +338,7 @@ public String retrieveTransferTemplate(@PathParam("clientId") final Long clientI
328338
@Produces({ MediaType.APPLICATION_JSON })
329339
@Operation(summary = "Retrieve a Client by External Id", description = "Example Requests:\n" + "\n" + "clients/123-456\n" + "\n" + "\n"
330340
+ "clients/123-456?template=true\n" + "\n" + "\n" + "clients/123-456?fields=id,displayName,officeName")
331-
@ApiResponses({
332-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsClientIdResponse.class))) })
341+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsClientIdResponse.class)))
333342
public String retrieveOne(@PathParam("externalId") @Parameter(description = "externalId") final String externalId,
334343
@Context final UriInfo uriInfo,
335344
@DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") @Parameter(description = "staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly) {
@@ -359,8 +368,7 @@ public String retrieveAssociatedAccounts(@PathParam("externalId") @Parameter(des
359368
+ "Changing the relationship between a client and its office is not supported through this API. An API specific to handling transfers of clients between offices is available for the same.\n"
360369
+ "\n" + "The relationship between a client and a group must be removed through the Groups API.")
361370
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdRequest.class)))
362-
@ApiResponses({
363-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdResponse.class))) })
371+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PutClientsClientIdResponse.class)))
364372
public String update(@Parameter(description = "externalId") @PathParam("externalId") final String externalId,
365373
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
366374
return updateClient(null, externalId, apiRequestBodyAsJson);
@@ -401,8 +409,7 @@ public String update(@Parameter(description = "externalId") @PathParam("external
401409
+ "Abstraction over the Propose and Accept Client Transfer API's which enable a user with Data Scope over both the Target and Destination Branches to directly transfer a Client to the destination Office.\n\n"
402410
+ "Showing request/response for 'Reject a Client Transfer'")
403411
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdRequest.class)))
404-
@ApiResponses({
405-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdResponse.class))) })
412+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.PostClientsClientIdResponse.class)))
406413
public String applyCommand(@PathParam("externalId") @Parameter(description = "externalId") final String externalId,
407414
@QueryParam("command") @Parameter(description = "command") final String commandParam,
408415
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
@@ -414,8 +421,7 @@ public String applyCommand(@PathParam("externalId") @Parameter(description = "ex
414421
@Consumes({ MediaType.APPLICATION_JSON })
415422
@Produces({ MediaType.APPLICATION_JSON })
416423
@Operation(summary = "Delete a Client", description = "If a client is in Pending state, you are allowed to Delete it. The delete is a 'hard delete' and cannot be recovered from. Once clients become active or have loans or savings associated with them, you cannot delete the client but you may Close the client if they have left the program.")
417-
@ApiResponses({
418-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.DeleteClientsClientIdResponse.class))) })
424+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.DeleteClientsClientIdResponse.class)))
419425
public String delete(@PathParam("externalId") @Parameter(description = "externalId") final String externalId) {
420426
return deleteClient(null, externalId);
421427
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.fineract.portfolio.client.data;
21+
22+
import java.io.Serializable;
23+
import java.math.BigDecimal;
24+
25+
/**
26+
* Data object for Client Performance metrics.
27+
*/
28+
public final class ClientPerformanceData implements Serializable {
29+
30+
private final Integer activeLoans;
31+
private final BigDecimal totalOutstandingBalance;
32+
33+
// Constructor is private to force use of the static 'instance' method (Fineract style)
34+
private ClientPerformanceData(final Integer activeLoans, final BigDecimal totalOutstandingBalance) {
35+
this.activeLoans = activeLoans;
36+
this.totalOutstandingBalance = totalOutstandingBalance;
37+
}
38+
39+
public static ClientPerformanceData instance(final Integer activeLoans, final BigDecimal totalOutstandingBalance) {
40+
return new ClientPerformanceData(activeLoans, totalOutstandingBalance);
41+
}
42+
43+
// Getters for Spring to convert this to JSON
44+
public Integer getActiveLoans() {
45+
return activeLoans;
46+
}
47+
48+
public BigDecimal getTotalOutstandingBalance() {
49+
return totalOutstandingBalance;
50+
}
51+
}

fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.apache.fineract.infrastructure.core.service.Page;
2525
import org.apache.fineract.infrastructure.core.service.SearchParameters;
2626
import org.apache.fineract.portfolio.client.data.ClientData;
27+
import org.apache.fineract.portfolio.client.data.ClientPerformanceData;
2728

2829
public interface ClientReadPlatformService {
2930

@@ -60,4 +61,6 @@ public interface ClientReadPlatformService {
6061

6162
Long retrieveClientIdByExternalId(ExternalId externalId);
6263

64+
ClientPerformanceData retrieveClientPerformance(Long clientId);
65+
6366
}

fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.apache.fineract.portfolio.client.data.ClientCollateralManagementData;
4848
import org.apache.fineract.portfolio.client.data.ClientData;
4949
import org.apache.fineract.portfolio.client.data.ClientNonPersonData;
50+
import org.apache.fineract.portfolio.client.data.ClientPerformanceData;
5051
import org.apache.fineract.portfolio.client.data.ClientTimelineData;
5152
import org.apache.fineract.portfolio.client.domain.Client;
5253
import org.apache.fineract.portfolio.client.domain.ClientEnumerations;
@@ -601,6 +602,28 @@ public Long retrieveClientIdByExternalId(final ExternalId externalId) {
601602
return clientRepositoryWrapper.findIdByExternalId(externalId);
602603
}
603604

605+
@Override
606+
public ClientPerformanceData retrieveClientPerformance(final Long clientId) {
607+
try {
608+
final String sql = """
609+
SELECT
610+
COUNT(l.id) as activeLoans,
611+
COALESCE(SUM(l.principal_outstanding_derived + l.interest_outstanding_derived + l.fee_charges_outstanding_derived + l.penalty_charges_outstanding_derived), 0) as totalOutstandingBalance
612+
FROM m_client c
613+
LEFT JOIN m_loan l ON l.client_id = c.id AND l.loan_status_id = 300
614+
WHERE c.id = ?
615+
GROUP BY c.id
616+
""";
617+
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
618+
final Integer activeLoans = rs.getInt("activeLoans");
619+
final BigDecimal totalOutstandingBalance = rs.getBigDecimal("totalOutstandingBalance");
620+
return ClientPerformanceData.instance(activeLoans, totalOutstandingBalance);
621+
}, clientId);
622+
} catch (EmptyResultDataAccessException e) {
623+
throw new ClientNotFoundException(clientId, e);
624+
}
625+
}
626+
604627
private static final class ClientToDataMapper implements RowMapper<ClientData> {
605628

606629
private final String schema;
@@ -773,5 +796,4 @@ public ClientData mapRow(final ResultSet rs, final int rowNum) throws SQLExcepti
773796

774797
}
775798
}
776-
777799
}

fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,5 @@
227227
<include file="parts/0206_transaction_summary_with_asset_owner_classification_name_bug_fix.xml" relativeToChangelogFile="true" />
228228
<include file="parts/0207_add_allow_full_term_for_tranche.xml" relativeToChangelogFile="true" />
229229
<include file="parts/0208_trial_balance_summary_with_asset_owner_journal_entry_aggregation_fix.xml" relativeToChangelogFile="true" />
230+
<include file="parts/0209_add_index_for_client_performance_api.xml" relativeToChangelogFile="true"/>
230231
</databaseChangeLog>

0 commit comments

Comments
 (0)