From b89e653bcb29a028c52e82f7048d2af6520fce6f Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Thu, 28 May 2026 17:40:57 +0530 Subject: [PATCH] feat: cash management backend - transfer, settlement, inter-cashier, reconciliation + cashier /me position endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/CashInterCashierApiResource.java | 224 +++++++++++++ .../domain/CashInterCashier.java | 71 ++++ .../domain/CashInterCashierDetail.java | 39 +++ .../CashInterCashierDetailRepository.java | 10 + .../domain/CashInterCashierRepository.java | 11 + .../api/CashReconciliationApiResource.java | 310 ++++++++++++++++++ .../domain/CashReconciliation.java | 62 ++++ .../domain/CashReconciliationDetail.java | 45 +++ .../CashReconciliationDetailRepository.java | 10 + .../domain/CashReconciliationRepository.java | 11 + .../cashreport/api/CashReportApiResource.java | 291 ++++++++++++++++ .../api/CashSettlementApiResource.java | 254 ++++++++++++++ .../cashsettlement/domain/CashSettlement.java | 68 ++++ .../domain/CashSettlementDetail.java | 39 +++ .../CashSettlementDetailRepository.java | 10 + .../domain/CashSettlementRepository.java | 11 + .../api/CashTransferApiResource.java | 276 ++++++++++++++++ .../cashtransfer/domain/CashTransfer.java | 72 ++++ .../domain/CashTransferDetail.java | 43 +++ .../domain/CashTransferDetailRepository.java | 10 + .../domain/CashTransferRepository.java | 10 + .../teller/service/GlDispatchService.java | 57 ++++ .../db/changelog/tenant/changelog-tenant.xml | 7 + .../tenant/parts/0014_cash_transfer.xml | 48 +++ .../parts/0015_cash_transfer_add_ids.xml | 13 + .../tenant/parts/0016_cash_settlement.xml | 54 +++ .../tenant/parts/0017_cash_inter_cashier.xml | 55 ++++ .../tenant/parts/0018_cash_reconciliation.xml | 56 ++++ .../parts/0019_cash_reversal_columns.xml | 15 + .../tenant/parts/0020_cash_permissions.xml | 54 +++ 30 files changed, 2236 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashier.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetail.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetailRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliation.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetail.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetailRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlement.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetail.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetailRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransfer.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetail.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetailRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/teller/service/GlDispatchService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0014_cash_transfer.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0015_cash_transfer_add_ids.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0016_cash_settlement.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0017_cash_inter_cashier.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0018_cash_reconciliation.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0019_cash_reversal_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0020_cash_permissions.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java new file mode 100644 index 000000000..39803e8f7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java @@ -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 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 toMap(CashInterCashier e) { + Map 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 detailToMap(CashInterCashierDetail d) { + Map 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashier.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashier.java new file mode 100644 index 000000000..b78020cb7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashier.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetail.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetail.java new file mode 100644 index 000000000..e2ba005f1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetail.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetailRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetailRepository.java new file mode 100644 index 000000000..985f0c879 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierDetailRepository.java @@ -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 { + List findByInterCashierId(Long interCashierId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierRepository.java new file mode 100644 index 000000000..f8ee565e5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/domain/CashInterCashierRepository.java @@ -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 { + List findAllByOrderByCreatedAtDesc(); + List findByTellerIdOrderByCreatedAtDesc(Long tellerId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java new file mode 100644 index 000000000..69983ee93 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -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 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> rows = new ArrayList<>(); + balance.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) + .forEach(e -> { + Map 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 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 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 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 balance, List 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 toMap(CashReconciliation e) { + Map 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 detailToMap(CashReconciliationDetail d) { + Map 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliation.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliation.java new file mode 100644 index 000000000..0deda4686 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliation.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetail.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetail.java new file mode 100644 index 000000000..3368ce0b0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetail.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetailRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetailRepository.java new file mode 100644 index 000000000..7b626bf2d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationDetailRepository.java @@ -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 { + List findByReconciliationId(Long reconciliationId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationRepository.java new file mode 100644 index 000000000..02d326b27 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/domain/CashReconciliationRepository.java @@ -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 { + List findAllByOrderByCreatedAtDesc(); + List findByCashierIdOrderByReconDateDesc(Long cashierId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java new file mode 100644 index 000000000..f9c5c3031 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -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> 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 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 row : rows) { + depositTotal = depositTotal.add((BigDecimal) row.get("depositAmount")); + withdrawalTotal = withdrawalTotal.add((BigDecimal) row.get("withdrawalAmount")); + } + + Map 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 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> result = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> { + Map 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 cashierIds = jdbcTemplate.queryForList(idSql, Long.class); + List> 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 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 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 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 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> 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 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 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 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 balance, List 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; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java new file mode 100644 index 000000000..b6952145e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -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 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 map = toMap(header); + List> 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 toMap(CashSettlement e) { + Map 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 detailToMap(CashSettlementDetail d) { + Map 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlement.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlement.java new file mode 100644 index 000000000..427e0d949 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlement.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetail.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetail.java new file mode 100644 index 000000000..1ba23143e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetail.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetailRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetailRepository.java new file mode 100644 index 000000000..1b60e08c8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementDetailRepository.java @@ -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 { + List findBySettlementId(Long settlementId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementRepository.java new file mode 100644 index 000000000..0b905e2ca --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/domain/CashSettlementRepository.java @@ -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 { + List findAllByOrderByCreatedAtDesc(); + List findByFromCashierIdOrderByCreatedAtDesc(Long cashierId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java new file mode 100644 index 000000000..f4137d60b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -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 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 map = toMap(header); + List> 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 toMap(CashTransfer e) { + Map 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 detailToMap(CashTransferDetail d) { + Map 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransfer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransfer.java new file mode 100644 index 000000000..4f303ee17 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransfer.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetail.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetail.java new file mode 100644 index 000000000..e319ad680 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetail.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetailRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetailRepository.java new file mode 100644 index 000000000..1e7166525 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferDetailRepository.java @@ -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 { + List findByTransferId(Long transferId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferRepository.java new file mode 100644 index 000000000..2a68f0f15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/domain/CashTransferRepository.java @@ -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 { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/teller/service/GlDispatchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/teller/service/GlDispatchService.java new file mode 100644 index 000000000..7b11d5aa3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/teller/service/GlDispatchService.java @@ -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); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 7935a1da7..240fc4e61 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -320,4 +320,11 @@ + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0014_cash_transfer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0014_cash_transfer.xml new file mode 100644 index 000000000..5ad219222 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0014_cash_transfer.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0015_cash_transfer_add_ids.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0015_cash_transfer_add_ids.xml new file mode 100644 index 000000000..02a116bad --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0015_cash_transfer_add_ids.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0016_cash_settlement.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0016_cash_settlement.xml new file mode 100644 index 000000000..bae5ead4e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0016_cash_settlement.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0017_cash_inter_cashier.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0017_cash_inter_cashier.xml new file mode 100644 index 000000000..6cbe048ba --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0017_cash_inter_cashier.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0018_cash_reconciliation.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0018_cash_reconciliation.xml new file mode 100644 index 000000000..74d797665 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0018_cash_reconciliation.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0019_cash_reversal_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0019_cash_reversal_columns.xml new file mode 100644 index 000000000..d79002cf5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0019_cash_reversal_columns.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0020_cash_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0020_cash_permissions.xml new file mode 100644 index 000000000..8fed4bd53 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0020_cash_permissions.xml @@ -0,0 +1,54 @@ + + + + + + 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'); + + + +