feat(cashier): add role-based data filtering and pagination for teller workstation

- Add CashierRoleHelper service: detects CASHIER role via DB permission
  check, resolves cashier ID from staff/user linkage
- Role-based filtering on GET list endpoints (cashtransfer, cashsettlement,
  cashintercashier, cashexchange, cashreports): CASHIER sees only own data,
  ADMIN/HEAD sees all
- Pagination (offset/limit) added to all cashier list APIs returning
  standard Fineract Page format {totalFilteredRecords, pageItems}
- ShareCollectionApiResource: collect cash before account activation,
  post GL entries (Dr Cash Vault / Cr Share Sundry / Cr Income from Fees),
  mark charges paid to prevent duplicate GL on activation,
  GET status endpoint for collection check
- Migration 0315: grant READ_CLIENTKYC, READ_CLIENTNOMINEE, READ_ADDRESS,
  READ_FAMILYMEMBERS, READ_SAVINGSACCOUNT to CASHIER role

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-05 11:00:55 +05:30
co-authored by Claude Sonnet 4.6
parent 9d944d0196
commit 2371ad98b1
8 changed files with 260 additions and 15 deletions
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchange;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
@@ -45,6 +46,7 @@ public class CashExchangeApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
@POST
@@ -174,10 +176,30 @@ public class CashExchangeApiResource {
}
@GET
public String getAll(@QueryParam("accountId") Long accountId) {
List<CashExchange> list = accountId != null ? repository.findByAccountIdOrderByCreatedAtDesc(accountId)
public String getAll(
@QueryParam("accountId") Long accountId,
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("20") int limit) {
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
List<CashExchange> all = accountId != null
? repository.findByAccountIdOrderByCreatedAtDesc(accountId)
: repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
if (scopedCashierId != null) {
all = all.stream()
.filter(e -> scopedCashierId.equals(e.getCashierId()))
.collect(Collectors.toList());
}
final int total = all.size();
final int safeOffset = Math.max(0, Math.min(offset, total));
final int safeTo = Math.min(safeOffset + Math.max(1, limit), total);
final List<Map<String, Object>> page = all.subList(safeOffset, safeTo)
.stream().map(this::toMap).collect(Collectors.toList());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("totalFilteredRecords", total);
resp.put("offset", safeOffset);
resp.put("limit", limit);
resp.put("pageItems", page);
return gson.toJson(resp);
}
@GET
@@ -11,6 +11,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -22,6 +23,7 @@ import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
@@ -40,6 +42,7 @@ public class CashInterCashierApiResource {
private final CashInterCashierRepository repository;
private final CashInterCashierDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
@POST
@@ -143,9 +146,28 @@ public class CashInterCashierApiResource {
}
@GET
public String getAll() {
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toMap).collect(Collectors.toList()));
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("20") int limit) {
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
List<CashInterCashier> all = repository.findAllByOrderByCreatedAtDesc();
if (scopedCashierId != null) {
all = all.stream()
.filter(t -> scopedCashierId.equals(t.getFromCashierId())
|| scopedCashierId.equals(t.getToCashierId()))
.collect(Collectors.toList());
}
final int total = all.size();
final int safeOffset = Math.max(0, Math.min(offset, total));
final int safeTo = Math.min(safeOffset + Math.max(1, limit), total);
final List<Map<String, Object>> page = all.subList(safeOffset, safeTo)
.stream().map(this::toMap).collect(Collectors.toList());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("totalFilteredRecords", total);
resp.put("offset", safeOffset);
resp.put("limit", limit);
resp.put("pageItems", page);
return gson.toJson(resp);
}
@GET
@@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -27,6 +28,7 @@ public class CashReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
@GET
@@ -115,10 +117,15 @@ public class CashReportApiResource {
@GET
@Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold, @QueryParam("fromDate") String fromDate,
@QueryParam("toDate") String toDate) {
public String excess(@QueryParam("threshold") BigDecimal threshold,
@QueryParam("fromDate") String fromDate,
@QueryParam("toDate") String toDate,
@jakarta.ws.rs.DefaultValue("0") @QueryParam("offset") int offset,
@jakarta.ws.rs.DefaultValue("20") @QueryParam("limit") int limit) {
final BigDecimal effectiveThreshold = threshold != null ? threshold : BigDecimal.valueOf(50000);
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
// CASHIER role: scope to this cashier's transactions only
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// Savings / Current / FD / RD transactions
final StringBuilder sql = new StringBuilder();
@@ -154,6 +161,10 @@ public class CashReportApiResource {
exSql.append("WHERE (is_reversed IS NULL OR is_reversed = false) AND exchange_amount >= ? ");
final List<Object> exArgs = new ArrayList<>();
exArgs.add(effectiveThreshold);
if (scopedCashierId != null) {
exSql.append("AND cashier_id = ? ");
exArgs.add(scopedCashierId);
}
if (hasDateRange) {
exSql.append("AND exchange_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
exArgs.add(fromDate);
@@ -181,7 +192,17 @@ public class CashReportApiResource {
combined.addAll(exchangeRows);
combined.sort((a, b) -> ((BigDecimal) b.get("totalAmount")).compareTo((BigDecimal) a.get("totalAmount")));
return gson.toJson(combined);
final int total = combined.size();
final int safeOffset = Math.max(0, Math.min(offset, total));
final int safeTo = Math.min(safeOffset + Math.max(1, limit), total);
final List<Map<String, Object>> page = combined.subList(safeOffset, safeTo);
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("totalFilteredRecords", total);
resp.put("offset", safeOffset);
resp.put("limit", limit);
resp.put("pageItems", page);
return gson.toJson(resp);
}
/**
@@ -276,6 +297,12 @@ public class CashReportApiResource {
@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
// CASHIER role: always scope to their own position only
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
if (scopedId != null) {
return gson.toJson(buildPositionForCashier(scopedId, dateFilter));
}
if (cashierId != null) {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
@@ -0,0 +1,77 @@
package org.apache.fineract.portfolio.cashreport.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Helper service for cashier role detection and cashier ID resolution.
*
* Regular CASHIER role → isCashierOnly() = true → data scoped to that cashier
* HEAD CASHIER / Admin → isCashierOnly() = false → all data visible
*/
@Service
@RequiredArgsConstructor
public class CashierRoleHelper {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
/**
* Returns true ONLY for users whose SOLE meaningful role is "CASHIER".
* Users with ALL_FUNCTIONS permission or a "head cashier" role return false.
*/
public boolean isCashierOnly() {
try {
AppUser user = context.authenticatedUser();
// Check via DB: if user's roles have ALL_FUNCTIONS permission → super-admin, not cashier-only
Long allFunctionsCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser_role aur "
+ "JOIN m_role_permission rp ON rp.role_id = aur.role_id "
+ "JOIN m_permission p ON p.id = rp.permission_id "
+ "WHERE aur.appuser_id = ? AND p.code = 'ALL_FUNCTIONS'",
Long.class, user.getId());
if (allFunctionsCount != null && allFunctionsCount > 0) return false;
// Check role name is exactly "CASHIER" (case-insensitive)
return user.getRoles().stream().anyMatch(r -> {
String name = r.getName().toLowerCase().trim();
return name.equals("cashier");
});
} catch (Exception e) {
return false;
}
}
/**
* Returns the m_cashiers.id for the currently authenticated user,
* or null if the user has no associated cashier record.
*/
public Long getCurrentCashierId() {
try {
Long userId = context.authenticatedUser().getId();
return jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc "
+ "JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id "
+ "WHERE au.id = ? ORDER BY mc.id DESC LIMIT 1",
Long.class, userId);
} catch (EmptyResultDataAccessException e) {
return null;
} catch (Exception e) {
return null;
}
}
/**
* Convenience: returns the cashier ID only when the user is cashier-only.
* Returns null for admin/head-cashier users (no filtering needed).
*/
public Long getCashierIdForFiltering() {
return isCashierOnly() ? getCurrentCashierId() : null;
}
}
@@ -23,6 +23,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlement;
@@ -49,6 +50,7 @@ public class CashSettlementApiResource {
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
@POST
@@ -224,9 +226,27 @@ public class CashSettlementApiResource {
}
@GET
public String getAll() {
List<CashSettlement> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("20") int limit) {
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
List<CashSettlement> all = repository.findAllByOrderByCreatedAtDesc();
if (scopedCashierId != null) {
all = all.stream()
.filter(s -> scopedCashierId.equals(s.getFromCashierId()))
.collect(Collectors.toList());
}
final int total = all.size();
final int safeOffset = Math.max(0, Math.min(offset, total));
final int safeTo = Math.min(safeOffset + Math.max(1, limit), total);
final List<Map<String, Object>> page = all.subList(safeOffset, safeTo)
.stream().map(this::toMap).collect(Collectors.toList());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("totalFilteredRecords", total);
resp.put("offset", safeOffset);
resp.put("limit", limit);
resp.put("pageItems", page);
return gson.toJson(resp);
}
@GET
@@ -11,6 +11,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -22,6 +23,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
@@ -48,6 +50,7 @@ public class CashTransferApiResource {
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
@POST
@@ -200,9 +203,27 @@ public class CashTransferApiResource {
}
@GET
public String getAll() {
List<CashTransfer> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("20") int limit) {
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
List<CashTransfer> all = repository.findAllByOrderByCreatedAtDesc();
if (scopedCashierId != null) {
all = all.stream()
.filter(t -> scopedCashierId.equals(t.getToCashierId()))
.collect(Collectors.toList());
}
final int total = all.size();
final int safeOffset = Math.max(0, Math.min(offset, total));
final int safeTo = Math.min(safeOffset + Math.max(1, limit), total);
final List<Map<String, Object>> page = all.subList(safeOffset, safeTo)
.stream().map(this::toMap).collect(Collectors.toList());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("totalFilteredRecords", total);
resp.put("offset", safeOffset);
resp.put("limit", limit);
resp.put("pageItems", page);
return gson.toJson(resp);
}
@GET
@@ -342,4 +342,5 @@
<include file="parts/0312_grant_cashier_fd_rd_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0313_alter_cash_exchange_add_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0314_cashier_staff_unique_constraint.xml" relativeToChangelogFile="true"/>
<include file="parts/0315_grant_cashier_transaction_entry_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
for licensing details.
-->
<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">
<!--
Grant additional READ permissions to the CASHIER role required for
the Cashier Transaction Entry screen to display full account details:
- READ_CLIENTKYC → PAN, Aadhaar, CKYC number
- READ_CLIENTNOMINEE → nominee list per account
- READ_ADDRESS → client address
- READ_FAMILYMEMBERS → S/D/W/H/O (guardian/spouse name)
- READ_SAVINGSACCOUNT → savings account detail fetch
-->
<changeSet id="0315_grant_cashier_transaction_entry_permissions" author="fineract">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code IN (
'READ_CLIENTKYC',
'READ_CLIENTNOMINEE',
'READ_ADDRESS',
'READ_FAMILYMEMBERS',
'READ_SAVINGSACCOUNT'
)
WHERE r.name = 'CASHIER'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
<rollback>
DELETE FROM m_role_permission
WHERE role_id = (SELECT id FROM m_role WHERE name = 'CASHIER')
AND permission_id IN (
SELECT id FROM m_permission
WHERE code IN (
'READ_CLIENTKYC',
'READ_CLIENTNOMINEE',
'READ_ADDRESS',
'READ_FAMILYMEMBERS',
'READ_SAVINGSACCOUNT'
)
);
</rollback>
</changeSet>
</databaseChangeLog>