feat: cash management backend - transfer, settlement, inter-cashier, reconciliation + cashier /me position endpoint

OVERVIEW
--------
Added complete cash management backend covering the full cashier workflow:
vault transfers, inter-cashier transfers, end-of-day settlement, GL
reconciliation, and a new authenticated /me endpoint for cashier position
lookup without needing to pass a cashier ID.

NEW API ENDPOINT
----------------
GET /cashreports/cashier-position/me
- Auto-detects the authenticated user via PlatformSecurityContext
- Resolves appUser -> staff -> cashier via m_cashiers JOIN chain
- Accepts optional ?asOfDate=YYYY-MM-DD query param (defaults to today)
- Reuses existing buildPositionForCashier(cashierId, dateFilter) internally
- Returns: { cashierId, asOfDate, totalAmount, denominations[] }
- Non-cashier users get empty denominations array (no error thrown)
- Used by Angular UI to show opening/closing denomination panels

CASH TRANSFER (vault -> cashier)
---------------------------------
CashTransferApiResource.java   — REST endpoints (POST /cashtransfer, GET list/detail)
CashTransfer.java              — JPA entity (m_cash_transfer)
CashTransferDetail.java        — JPA entity (denomination breakdown, m_cash_transfer_detail)
CashTransferRepository.java    — Spring Data JPA repository
CashTransferDetailRepository.java

DB migrations:
  0014_cash_transfer.xml       — creates m_cash_transfer + m_cash_transfer_detail tables
  0015_cash_transfer_add_ids.xml — adds missing ID columns to transfer detail

CASH SETTLEMENT (end-of-day cashier close)
------------------------------------------
CashSettlementApiResource.java — REST endpoints (POST /cashsettlement, GET list/detail)
CashSettlement.java            — JPA entity (m_cash_settlement)
CashSettlementDetail.java      — JPA entity (denomination breakdown)
CashSettlementRepository.java
CashSettlementDetailRepository.java

DB migration:
  0016_cash_settlement.xml     — creates m_cash_settlement + m_cash_settlement_detail

INTER-CASHIER TRANSFER
-----------------------
CashInterCashierApiResource.java — REST endpoints (POST /cashintercashier, GET list/detail)
CashInterCashier.java            — JPA entity (m_cash_inter_cashier)
CashInterCashierDetail.java      — JPA entity (denomination breakdown)
CashInterCashierRepository.java
CashInterCashierDetailRepository.java

DB migration:
  0017_cash_inter_cashier.xml  — creates m_cash_inter_cashier + detail table

CASH RECONCILIATION
--------------------
CashReconciliationApiResource.java — REST endpoints (POST /cashreconciliation, GET list)
CashReconciliation.java            — JPA entity (m_cash_reconciliation)
CashReconciliationDetail.java      — JPA entity (denomination breakdown)
CashReconciliationRepository.java
CashReconciliationDetailRepository.java

DB migrations:
  0018_cash_reconciliation.xml — creates m_cash_reconciliation + detail table
  0019_cash_reversal_columns.xml — adds reversal tracking columns
  0020_cash_permissions.xml    — adds permissions for all cash management APIs

GL DISPATCH SERVICE
-------------------
GlDispatchService.java (portfolio/teller/service/)
- Shared service for posting GL journal entries for cash movements
- Used by settlement and reconciliation flows

CHANGELOG
---------
changelog-tenant.xml updated to include migrations 0014 through 0020.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-28 17:40:57 +05:30
co-authored by Claude Sonnet 4.6
parent 9a30ede278
commit b89e653bcb
30 changed files with 2236 additions and 0 deletions
@@ -0,0 +1,224 @@
package org.apache.fineract.portfolio.cashintercashier.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.cashintercashier.domain.CashInterCashier;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashintercashier")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashInterCashierApiResource {
private final CashInterCashierRepository repository;
private final CashInterCashierDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@POST
@Transactional
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHINTERCASHIER");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
? json.get("tellerId").getAsLong() : null;
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
? json.get("fromCashierId").getAsLong() : null;
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
? json.get("toCashierId").getAsLong() : null;
if (tellerId == null || fromCashierId == null || toCashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.parties.required",
"tellerId, fromCashierId and toCashierId are required.");
}
if (fromCashierId.equals(toCashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
"From-cashier and To-cashier cannot be the same.");
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now();
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
CashInterCashier header = new CashInterCashier();
header.setTransferDate(transferDate);
header.setTellerId(tellerId);
header.setFromCashierId(fromCashierId);
header.setToCashierId(toCashierId);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
CashInterCashier saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
CashInterCashier record = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
"Inter-cashier transfer not found: " + id));
if (Boolean.TRUE.equals(record.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
"Inter-cashier transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now());
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll() {
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashInterCashier header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Inter-cashier transfer not found: " + id));
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByInterCashierId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
}
return false;
}
private void saveDetails(Long interCashierId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashInterCashierDetail d = new CashInterCashierDetail();
d.setInterCashierId(interCashierId);
d.setDenominationId(denomId);
d.setDenominationValue(value);
d.setCount(count);
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(d);
}
}
private Map<String, Object> toMap(CashInterCashier e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
m.put("tellerId", e.getTellerId());
m.put("fromCashierId", e.getFromCashierId());
m.put("toCashierId", e.getToCashierId());
m.put("fromParty", e.getFromParty());
m.put("toParty", e.getToParty());
m.put("transferAmount", e.getTransferAmount());
m.put("note", e.getNote());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
m.put("isReversed", e.getIsReversed());
return m;
}
private Map<String, Object> detailToMap(CashInterCashierDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("count", d.getCount());
m.put("totalAmount", d.getTotalAmount());
return m;
}
}
@@ -0,0 +1,71 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_inter_cashier")
@Getter
@Setter
@NoArgsConstructor
public class CashInterCashier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_date")
private LocalDate transferDate;
@Column(name = "teller_id")
private Long tellerId;
@Column(name = "from_cashier_id")
private Long fromCashierId;
@Column(name = "to_cashier_id")
private Long toCashierId;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "transfer_amount", nullable = false)
private BigDecimal transferAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,39 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_inter_cashier_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashInterCashierDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "inter_cashier_id", nullable = false)
private Long interCashierId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashInterCashierDetailRepository extends JpaRepository<CashInterCashierDetail, Long> {
List<CashInterCashierDetail> findByInterCashierId(Long interCashierId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashInterCashierRepository extends JpaRepository<CashInterCashier, Long> {
List<CashInterCashier> findAllByOrderByCreatedAtDesc();
List<CashInterCashier> findByTellerIdOrderByCreatedAtDesc(Long tellerId);
}
@@ -0,0 +1,310 @@
package org.apache.fineract.portfolio.cashreconciliation.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.cashreconciliation.domain.CashReconciliation;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashreconciliation")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashReconciliationApiResource {
private final CashReconciliationRepository repository;
private final CashReconciliationDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
/**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier.
* Computation: allocations (m_cash_transfer_detail where to_cashier_id=X)
* + inter-cashier-in (m_cash_inter_cashier_detail where to_cashier_id=X)
* - settlements (m_cash_settlement_detail where from_cashier_id=X)
* - inter-cashier-out (m_cash_inter_cashier_detail where from_cashier_id=X)
*/
@GET
@Path("expected")
public String getExpected(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
+ "FROM m_cash_transfer_detail ctd "
+ "JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ "AND ct.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
// In: inter-cashier in
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd "
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// In: customer deposits received by this cashier
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Out: settlements to vault
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
+ "FROM m_cash_settlement_detail csd "
+ "JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
+ "AND cs.settlement_date <= CAST(? AS DATE) "
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
// Out: inter-cashier out
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd "
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Out: customer withdrawals paid by this cashier
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
.forEach(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("expectedCount", e.getValue()[2]);
row.put("expectedAmount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
BigDecimal grandTotal = rows.stream()
.map(r -> (BigDecimal) r.get("expectedAmount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
JsonObject result = new JsonObject();
result.addProperty("cashierId", cashierId);
result.addProperty("asOfDate", dateFilter);
result.addProperty("expectedTotal", grandTotal);
result.add("denominations", gson.toJsonTree(rows));
return gson.toJson(result);
}
@POST
@Transactional
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHRECONCILIATION");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long cashierId = json.has("cashierId") ? json.get("cashierId").getAsLong() : null;
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId is required.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now();
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
? json.get("cashierName").getAsString() : null;
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
? json.get("tellerId").getAsLong() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
BigDecimal expectedTotal = BigDecimal.ZERO;
BigDecimal physicalTotal = BigDecimal.ZERO;
List<CashReconciliationDetail> details = new ArrayList<>();
for (JsonElement el : denominations) {
JsonObject item = el.getAsJsonObject();
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
int varianceCount = physicalCount - expectedCount;
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
expectedTotal = expectedTotal.add(BigDecimal.valueOf((long) denomValue * expectedCount));
physicalTotal = physicalTotal.add(BigDecimal.valueOf((long) denomValue * physicalCount));
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashReconciliationDetail d = new CashReconciliationDetail();
d.setDenominationId(denomId);
d.setDenominationValue(denomValue);
d.setExpectedCount(expectedCount);
d.setPhysicalCount(physicalCount);
d.setVarianceCount(varianceCount);
d.setVarianceAmount(varianceAmount);
details.add(d);
}
BigDecimal variance = physicalTotal.subtract(expectedTotal);
String status = variance.compareTo(BigDecimal.ZERO) == 0 ? "OK"
: variance.compareTo(BigDecimal.ZERO) > 0 ? "EXCESS" : "SHORT";
CashReconciliation header = new CashReconciliation();
header.setReconDate(reconDate);
header.setCashierId(cashierId);
header.setCashierName(cashierName);
header.setTellerId(tellerId);
header.setExpectedAmount(expectedTotal);
header.setPhysicalAmount(physicalTotal);
header.setVariance(variance);
header.setStatus(status);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
CashReconciliation saved = repository.saveAndFlush(header);
for (CashReconciliationDetail d : details) {
d.setReconciliationId(saved.getId());
detailRepository.save(d);
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
result.addProperty("status", status);
result.addProperty("variance", variance);
return gson.toJson(result);
}
@GET
public String getAll() {
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashReconciliation header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
return gson.toJson(map);
}
// Row mapper: Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
(rs, r) -> new Object[] {
rs.getLong("denomination_id"),
rs.getInt("denomination_value"),
rs.getString("kind"),
rs.getLong("total_count")
};
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
for (Object[] row : rows) {
Long denomId = (Long) row[0];
int denomValue = (Integer) row[1];
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
long count = (Long) row[3];
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
private Map<String, Object> toMap(CashReconciliation e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("reconDate", e.getReconDate() != null ? e.getReconDate().toString() : null);
m.put("cashierId", e.getCashierId());
m.put("cashierName", e.getCashierName());
m.put("tellerId", e.getTellerId());
m.put("expectedAmount", e.getExpectedAmount());
m.put("physicalAmount", e.getPhysicalAmount());
m.put("variance", e.getVariance());
m.put("status", e.getStatus());
m.put("note", e.getNote());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
return m;
}
private Map<String, Object> detailToMap(CashReconciliationDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("expectedCount", d.getExpectedCount());
m.put("physicalCount", d.getPhysicalCount());
m.put("varianceCount", d.getVarianceCount());
m.put("varianceAmount", d.getVarianceAmount());
return m;
}
}
@@ -0,0 +1,62 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_reconciliation")
@Getter
@Setter
@NoArgsConstructor
public class CashReconciliation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "recon_date", nullable = false)
private LocalDate reconDate;
@Column(name = "cashier_id", nullable = false)
private Long cashierId;
@Column(name = "cashier_name", length = 150)
private String cashierName;
@Column(name = "teller_id")
private Long tellerId;
@Column(name = "expected_amount")
private BigDecimal expectedAmount = BigDecimal.ZERO;
@Column(name = "physical_amount")
private BigDecimal physicalAmount = BigDecimal.ZERO;
@Column(name = "variance")
private BigDecimal variance = BigDecimal.ZERO;
@Column(name = "status", length = 10)
private String status = "OK";
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
}
@@ -0,0 +1,45 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_reconciliation_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashReconciliationDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "reconciliation_id", nullable = false)
private Long reconciliationId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "expected_count")
private Integer expectedCount = 0;
@Column(name = "physical_count")
private Integer physicalCount = 0;
@Column(name = "variance_count")
private Integer varianceCount = 0;
@Column(name = "variance_amount")
private BigDecimal varianceAmount = BigDecimal.ZERO;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashReconciliationDetailRepository extends JpaRepository<CashReconciliationDetail, Long> {
List<CashReconciliationDetail> findByReconciliationId(Long reconciliationId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashReconciliationRepository extends JpaRepository<CashReconciliation, Long> {
List<CashReconciliation> findAllByOrderByCreatedAtDesc();
List<CashReconciliation> findByCashierIdOrderByReconDateDesc(Long cashierId);
}
@@ -0,0 +1,291 @@
package org.apache.fineract.portfolio.cashreport.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Path("/v1/cashreports")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class CashReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
+ "ORDER BY std.denomination_value DESC, dm.kind";
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count");
long withdrawalCount = rs.getLong("withdrawal_count");
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("depositCount", depositCount);
row.put("depositAmount", depositAmount);
row.put("withdrawalCount", withdrawalCount);
row.put("withdrawalAmount", withdrawalAmount);
row.put("netCount", depositCount - withdrawalCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
return row;
}, txnDate);
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
for (Map<String, Object> row : rows) {
depositTotal = depositTotal.add((BigDecimal) row.get("depositAmount"));
withdrawalTotal = withdrawalTotal.add((BigDecimal) row.get("withdrawalAmount"));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", rows);
result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
return gson.toJson(result);
}
@GET
@Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold, @QueryParam("fromDate") String fromDate,
@QueryParam("toDate") String toDate) {
final BigDecimal effectiveThreshold = threshold != null ? threshold : BigDecimal.valueOf(50000);
final StringBuilder sql = new StringBuilder();
sql.append("SELECT txn_id, MAX(account_no) account_no, MAX(client_id) client_id, MAX(cif_no) cif_no, ");
sql.append("MAX(txn_date) txn_date, SUM(total_amount) total_amount FROM m_savings_txn_denomination ");
final List<Object> args = new ArrayList<>();
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
if (hasDateRange) {
sql.append("WHERE txn_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
args.add(fromDate);
args.add(toDate);
}
sql.append("GROUP BY txn_id HAVING SUM(total_amount) >= ? ORDER BY total_amount DESC");
args.add(effectiveThreshold);
final List<Map<String, Object>> result = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("txnId", rs.getLong("txn_id"));
row.put("accountNo", rs.getString("account_no"));
row.put("clientId", rs.getObject("client_id"));
row.put("cifNo", rs.getString("cif_no"));
final java.sql.Date txnDate = rs.getDate("txn_date");
row.put("txnDate", txnDate != null ? txnDate.toString() : null);
row.put("totalAmount", rs.getBigDecimal("total_amount"));
return row;
}, args.toArray());
return gson.toJson(result);
}
/**
* GET /cashreports/cashier-position?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise net position per cashier grouped by denomination_id+kind.
*/
@GET
@Path("cashier-position")
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
if (cashierId != null) {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
final String idSql = "SELECT DISTINCT cashier_id FROM ("
+ " SELECT to_cashier_id AS cashier_id FROM m_cash_transfer WHERE to_cashier_id IS NOT NULL "
+ " UNION SELECT from_cashier_id FROM m_cash_settlement WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT from_cashier_id FROM m_cash_inter_cashier WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT to_cashier_id FROM m_cash_inter_cashier WHERE to_cashier_id IS NOT NULL "
+ ") ids";
List<Long> cashierIds = jdbcTemplate.queryForList(idSql, Long.class);
List<Map<String, Object>> allPositions = new ArrayList<>();
for (Long cid : cashierIds) {
allPositions.add(buildPositionForCashier(cid, dateFilter));
}
return gson.toJson(allPositions);
}
@GET
@Path("cashier-position/me")
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
} catch (Exception e) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("error", "Not authenticated");
return gson.toJson(err);
}
Long cashierId;
try {
cashierId = 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 = ?",
Long.class, appUserId);
} catch (Exception e) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
empty.put("asOfDate", dateFilter);
empty.put("totalAmount", BigDecimal.ZERO);
empty.put("denominations", new ArrayList<>());
return gson.toJson(empty);
}
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ "AND ct.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
// Inter-cashier IN
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Customer deposits
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
+ "FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
+ "AND cs.settlement_date <= CAST(? AS DATE) "
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
// Inter-cashier OUT
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Customer withdrawals
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
.filter(e -> e.getValue()[2] != 0)
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
.forEach(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("count", e.getValue()[2]);
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
BigDecimal totalAmount = rows.stream()
.map(r -> (BigDecimal) r.get("amount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
String cashierName = null;
try {
cashierName = jdbcTemplate.queryForObject(
"SELECT ms.display_name FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id WHERE mc.id = ?",
String.class, cashierId);
} catch (Exception ignored) {}
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
result.put("cashierName", cashierName);
result.put("asOfDate", dateFilter);
result.put("totalAmount", totalAmount);
result.put("denominations", rows);
return result;
}
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
(rs, r) -> new Object[] {
rs.getLong("denomination_id"),
rs.getInt("denomination_value"),
rs.getString("kind"),
rs.getLong("total_count")
};
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
for (Object[] row : rows) {
Long denomId = (Long) row[0];
int denomValue = (Integer) row[1];
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
long count = (Long) row[3];
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
}
@@ -0,0 +1,254 @@
package org.apache.fineract.portfolio.cashsettlement.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.cashsettlement.domain.CashSettlement;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@Path("/v1/cashsettlement")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashSettlementApiResource {
private final CashSettlementRepository repository;
private final CashSettlementDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final Gson gson = new Gson();
@POST
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
? json.get("fromCashierId").getAsLong() : null;
final Long toTellerId = json.has("toTellerId") && !json.get("toTellerId").isJsonNull()
? json.get("toTellerId").getAsLong() : null;
if (fromCashierId == null || toTellerId == null) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.parties.required",
"fromCashierId and toTellerId are required.");
}
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now();
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
? json.get("currencyCode").getAsString() : "INR";
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
CashSettlement header = new CashSettlement();
header.setSettlementDate(settlementDate);
header.setFromCashierId(fromCashierId);
header.setToTellerId(toTellerId);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setSettlementAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
// Commit header + details first, then GL dispatch separately to avoid
// EclipseLink flushing pending detail rows inside Fineract's GL transaction.
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
CashSettlement saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
return saved.getId();
});
String glWarning = null;
try {
glDispatchService.settleCashFromCashier(toTellerId, fromCashierId, total,
currencyCode, settlementDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
} catch (Exception e) {
glWarning = "Settlement saved but GL entry failed: " + e.getMessage();
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", savedId);
if (glWarning != null) result.addProperty("glWarning", glWarning);
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
CashSettlement settlement = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
"Settlement not found: " + id));
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
"Settlement " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now());
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
try {
glDispatchService.allocateCashToCashier(settlement.getToTellerId(),
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll() {
List<CashSettlement> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashSettlement header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash settlement not found: " + id));
Map<String, Object> map = toMap(header);
List<Map<String, Object>> details = detailRepository.findBySettlementId(id).stream()
.map(this::detailToMap).collect(Collectors.toList());
map.put("details", details);
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
}
return false;
}
private void saveDetails(Long settlementId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashSettlementDetail d = new CashSettlementDetail();
d.setSettlementId(settlementId);
d.setDenominationId(denomId);
d.setDenominationValue(value);
d.setCount(count);
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(d);
}
}
private Map<String, Object> toMap(CashSettlement e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("settlementDate", e.getSettlementDate() != null ? e.getSettlementDate().toString() : null);
m.put("fromCashierId", e.getFromCashierId());
m.put("toTellerId", e.getToTellerId());
m.put("fromParty", e.getFromParty());
m.put("toParty", e.getToParty());
m.put("settlementAmount", e.getSettlementAmount());
m.put("note", e.getNote());
m.put("performedBy", e.getPerformedBy());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
m.put("isReversed", e.getIsReversed());
m.put("reversedBy", e.getReversedBy());
m.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
m.put("reverseReason", e.getReverseReason());
return m;
}
private Map<String, Object> detailToMap(CashSettlementDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("count", d.getCount());
m.put("totalAmount", d.getTotalAmount());
return m;
}
}
@@ -0,0 +1,68 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_settlement")
@Getter
@Setter
@NoArgsConstructor
public class CashSettlement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "settlement_date")
private LocalDate settlementDate;
@Column(name = "from_cashier_id")
private Long fromCashierId;
@Column(name = "to_teller_id")
private Long toTellerId;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "settlement_amount", nullable = false)
private BigDecimal settlementAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,39 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_settlement_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashSettlementDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "settlement_id", nullable = false)
private Long settlementId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashSettlementDetailRepository extends JpaRepository<CashSettlementDetail, Long> {
List<CashSettlementDetail> findBySettlementId(Long settlementId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashSettlementRepository extends JpaRepository<CashSettlement, Long> {
List<CashSettlement> findAllByOrderByCreatedAtDesc();
List<CashSettlement> findByFromCashierIdOrderByCreatedAtDesc(Long cashierId);
}
@@ -0,0 +1,276 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
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.teller.service.GlDispatchService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetail;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetailRepository;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@Path("/v1/cashtransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashTransferApiResource {
private final CashTransferRepository repository;
private final CashTransferDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final Gson gson = new Gson();
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString())
: null;
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString()
: null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() ? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final Long fromTellerId = json.has("fromTellerId") && !json.get("fromTellerId").isJsonNull()
? json.get("fromTellerId").getAsLong()
: null;
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
? json.get("toCashierId").getAsLong()
: null;
if (fromTellerId == null || toCashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.parties.required",
"fromTellerId and toCashierId are required.");
}
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
? json.get("currencyCode").getAsString()
: "INR";
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations")
: new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
"At least one denomination with a positive count is required.");
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setFromTellerId(fromTellerId);
header.setToCashierId(toCashierId);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
// ALLOCATECASHTOCASHIER transaction, which caused null transfer_id constraint errors.
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
CashTransfer saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
return saved.getId();
});
String glWarning = null;
try {
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now();
glDispatchService.allocateCashToCashier(fromTellerId, toCashierId, total,
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
} catch (Exception e) {
glWarning = "Transfer saved but GL entry failed: " + e.getMessage();
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", savedId);
if (glWarning != null) result.addProperty("glWarning", glWarning);
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
CashTransfer transfer = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
"Cash transfer not found: " + id));
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
"Cash transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now());
transfer.setReverseReason(reason);
repository.save(transfer);
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
try {
glDispatchService.settleCashFromCashier(transfer.getFromTellerId(),
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
java.time.LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll() {
List<CashTransfer> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashTransfer header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash transfer not found: " + id));
Map<String, Object> map = toMap(header);
List<Map<String, Object>> details = detailRepository.findByTransferId(id).stream().map(this::detailToMap)
.collect(Collectors.toList());
map.put("details", details);
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
}
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
}
return false;
}
private void saveDetails(Long transferId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashTransferDetail detail = new CashTransferDetail();
detail.setTransferId(transferId);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(detail);
}
}
private Map<String, Object> toMap(CashTransfer e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", e.getId());
map.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
map.put("fromParty", e.getFromParty());
map.put("toParty", e.getToParty());
map.put("fromTellerId", e.getFromTellerId());
map.put("toCashierId", e.getToCashierId());
map.put("transferAmount", e.getTransferAmount());
map.put("note", e.getNote());
map.put("performedBy", e.getPerformedBy());
map.put("performedByUsername", e.getPerformedByUsername());
map.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
return map;
}
private Map<String, Object> detailToMap(CashTransferDetail d) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", d.getId());
map.put("denominationId", d.getDenominationId());
map.put("denominationValue", d.getDenominationValue());
map.put("count", d.getCount());
map.put("totalAmount", d.getTotalAmount());
return map;
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashtransfer.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_transfer")
@Getter
@Setter
@NoArgsConstructor
public class CashTransfer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_date")
private LocalDate transferDate;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "transfer_amount", nullable = false)
private BigDecimal transferAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "from_teller_id")
private Long fromTellerId;
@Column(name = "to_cashier_id")
private Long toCashierId;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private java.time.LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashtransfer.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_transfer_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashTransferDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_id", nullable = false)
private Long transferId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashtransfer.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashTransferDetailRepository extends JpaRepository<CashTransferDetail, Long> {
List<CashTransferDetail> findByTransferId(Long transferId);
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashtransfer.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashTransferRepository extends JpaRepository<CashTransfer, Long> {
List<CashTransfer> findAllByOrderByCreatedAtDesc();
}
@@ -0,0 +1,57 @@
package org.apache.fineract.portfolio.teller.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class GlDispatchService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
/**
* Vault → Cashier GL entry. Runs in its own transaction so a failure
* never rolls back the caller's (already-committed) transfer record.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void allocateCashToCashier(Long tellerId, Long cashierId,
BigDecimal amount, String currencyCode, String txnDate, String notes) {
JsonObject body = new JsonObject();
body.addProperty("locale", "en");
body.addProperty("dateFormat", "yyyy-MM-dd");
body.addProperty("txnDate", txnDate);
body.addProperty("txnAmount", amount);
body.addProperty("currencyCode", currencyCode);
body.addProperty("txnNote", notes != null ? notes : "");
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
.allocateCashToCashier(tellerId, cashierId).build();
commandsSourceWritePlatformService.logCommandSource(wrapper);
}
/**
* Cashier → Vault GL entry (settlement / transfer reversal).
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void settleCashFromCashier(Long tellerId, Long cashierId,
BigDecimal amount, String currencyCode, String txnDate, String notes) {
JsonObject body = new JsonObject();
body.addProperty("locale", "en");
body.addProperty("dateFormat", "yyyy-MM-dd");
body.addProperty("txnDate", txnDate);
body.addProperty("txnAmount", amount);
body.addProperty("currencyCode", currencyCode);
body.addProperty("txnNote", notes != null ? notes : "");
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
.settleCashFromCashier(tellerId, cashierId).build();
commandsSourceWritePlatformService.logCommandSource(wrapper);
}
}
@@ -320,4 +320,11 @@
<include file="parts/0301_add_current_account_to_transfer_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0012_denomination_master.xml" relativeToChangelogFile="true"/>
<include file="parts/0013_cash_exchange.xml" relativeToChangelogFile="true"/>
<include file="parts/0014_cash_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0015_cash_transfer_add_ids.xml" relativeToChangelogFile="true"/>
<include file="parts/0016_cash_settlement.xml" relativeToChangelogFile="true"/>
<include file="parts/0017_cash_inter_cashier.xml" relativeToChangelogFile="true"/>
<include file="parts/0018_cash_reconciliation.xml" relativeToChangelogFile="true"/>
<include file="parts/0019_cash_reversal_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0020_cash_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_table_001" author="CBS">
<createTable tableName="m_cash_transfer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="cash_transfer_detail_table_001" author="CBS">
<createTable tableName="m_cash_transfer_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_transfer_detail" indexName="idx_cash_transfer_detail_tid">
<column name="transfer_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_add_party_ids_001" author="CBS">
<addColumn tableName="m_cash_transfer">
<column name="from_teller_id" type="BIGINT"/>
<column name="to_cashier_id" type="BIGINT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_settlement_table_001" author="CBS">
<createTable tableName="m_cash_settlement">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="settlement_date" type="DATE"/>
<column name="from_cashier_id" type="BIGINT"/>
<column name="to_teller_id" type="BIGINT"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="settlement_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</createTable>
</changeSet>
<changeSet id="cash_settlement_detail_table_001" author="CBS">
<createTable tableName="m_cash_settlement_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="settlement_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_settlement_detail" indexName="idx_cash_settlement_detail_sid">
<column name="settlement_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_inter_cashier_table_001" author="CBS">
<createTable tableName="m_cash_inter_cashier">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="teller_id" type="BIGINT"/>
<column name="from_cashier_id" type="BIGINT"/>
<column name="to_cashier_id" type="BIGINT"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</createTable>
</changeSet>
<changeSet id="cash_inter_cashier_detail_table_001" author="CBS">
<createTable tableName="m_cash_inter_cashier_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="inter_cashier_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_inter_cashier_detail" indexName="idx_cash_inter_cashier_detail_pid">
<column name="inter_cashier_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_reconciliation_table_001" author="CBS">
<createTable tableName="m_cash_reconciliation">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="recon_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="cashier_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="cashier_name" type="VARCHAR(150)"/>
<column name="teller_id" type="BIGINT"/>
<column name="expected_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="physical_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="variance" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="status" type="VARCHAR(10)" defaultValue="OK"/>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex tableName="m_cash_reconciliation" indexName="idx_cash_recon_cashier_date">
<column name="cashier_id"/>
<column name="recon_date"/>
</createIndex>
</changeSet>
<changeSet id="cash_reconciliation_detail_table_001" author="CBS">
<createTable tableName="m_cash_reconciliation_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="reconciliation_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="expected_count" type="INT" defaultValueNumeric="0"/>
<column name="physical_count" type="INT" defaultValueNumeric="0"/>
<column name="variance_count" type="INT" defaultValueNumeric="0"/>
<column name="variance_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
</createTable>
<createIndex tableName="m_cash_reconciliation_detail" indexName="idx_cash_recon_detail_rid">
<column name="reconciliation_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_reversal_cols_001" author="CBS">
<addColumn tableName="m_cash_transfer">
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_module_permissions_001" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHTRANSFER', 'CASHTRANSFER', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHTRANSFER', 'CASHTRANSFER', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHTRANSFER', 'CASHTRANSFER', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHSETTLEMENT', 'CASHSETTLEMENT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHINTERCASHIER', 'CASHINTERCASHIER', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHRECONCILIATION', 'CASHRECONCILIATION', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHRECONCILIATION');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHRECONCILIATION', 'CASHRECONCILIATION', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHRECONCILIATION');
</sql>
</changeSet>
</databaseChangeLog>