From 19639c9e020327bce3998056ddb15a57dccfe55b Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 10 Jun 2026 15:31:12 +0530 Subject: [PATCH] feat(cashier): add GL denomination tracking, live GL balance, and enhanced closing report - Add CashierGlDenominationApiResource: new POST /v1/cashier/gl-denomination endpoint to record denomination breakdown for GL journal entries in m_savings_txn_denomination - Fix GL account real-time balance in GLAccountReadPlatformServiceImpl using live SUM query - Add Liquibase migrations 0318/0319/0320: make savings_account_id and txn_id nullable, add payment_type column to m_savings_txn_denomination - Add validCheckSum to 0317b changeset to resolve checksum mismatch on existing installs - Enhance GL entries closing report SQL with date, time, teller, payment type, txn source Co-Authored-By: Claude Sonnet 4.6 --- .../GLAccountReadPlatformServiceImpl.java | 17 +- .../cashreport/api/CashReportApiResource.java | 18 +- .../api/CashierGlDenominationApiResource.java | 159 ++++++++++++++++++ .../domain/SavingsTxnDenomination.java | 8 +- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...cashier_gl_loan_share_read_permissions.xml | 38 +++++ ..._nullable_savings_account_denomination.xml | 23 +++ ...319_allow_nullable_txn_id_denomination.xml | 23 +++ .../0320_add_payment_type_to_denomination.xml | 20 +++ 9 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0318_allow_nullable_savings_account_denomination.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0319_allow_nullable_txn_id_denomination.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0320_add_payment_type_to_denomination.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/glaccount/service/GLAccountReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/glaccount/service/GLAccountReadPlatformServiceImpl.java index 12ec205e3..0aab7795d 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/glaccount/service/GLAccountReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/glaccount/service/GLAccountReadPlatformServiceImpl.java @@ -200,12 +200,21 @@ public class GLAccountReadPlatformServiceImpl implements GLAccountReadPlatformSe final StringBuilder sql = new StringBuilder(); sql.append("select ").append(rm.schema()); sql.append(" where gl.id = ?"); - if (associationParametersData.isRunningBalanceRequired()) { - sql.append(" and gl_j.is_running_balance_calculated = true ") - .append(" ORDER BY gl_j.entry_date DESC,gl_j.id DESC LIMIT 1"); + + final GLAccountData glAccount = this.jdbcTemplate.queryForObject(sql.toString(), rm, new Object[] { glAccountId }); + + // Live balance: sum all non-reversed journal entries so cashier sees real-time balance + // without waiting for the "Update Accounting Running Balances" scheduled job. + if (associationParametersData != null && associationParametersData.isRunningBalanceRequired() && glAccount != null) { + final String liveBalanceSql = "SELECT COALESCE(ABS(SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END)), 0) " + + "FROM acc_gl_journal_entry " + + "WHERE account_id = ? AND (reversed = false OR reversed IS NULL)"; + final java.math.BigDecimal liveBalance = this.jdbcTemplate.queryForObject(liveBalanceSql, + java.math.BigDecimal.class, glAccountId); + glAccount.setOrganizationRunningBalance(liveBalance != null ? liveBalance.longValue() : 0L); } - return this.jdbcTemplate.queryForObject(sql.toString(), rm, new Object[] { glAccountId }); + return glAccount; } catch (final EmptyResultDataAccessException e) { throw new GLAccountNotFoundException(glAccountId, e); } 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 index 54b35a956..e1b6e1fce 100644 --- 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 @@ -584,25 +584,35 @@ public class CashReportApiResource { params = new Object[] { txnDate }; } - // One row per transaction — account no, account holder name, dr/cr amount + // One row per transaction — account no, account holder name, dr/cr amount, date, teller, payment type final String sql = "SELECT " + " std.txn_id AS voucher_no, " + " std.account_no AS ac_no, " - + " COALESCE(mc.display_name, '') AS ac_name, " + + " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, " + + " MAX(std.txn_date) AS txn_date, " + + " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, " + + " MAX(std.officer_username) AS teller_name, " + + " MAX(std.payment_type) AS payment_type, " + + " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, " + " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, " + " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount " + "FROM m_savings_txn_denomination std " + "LEFT JOIN m_client mc ON mc.id = std.client_id " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter - + " GROUP BY std.txn_id, std.account_no, mc.display_name " - + "ORDER BY std.txn_id"; + + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no " + + "ORDER BY std.txn_id NULLS LAST, MAX(std.created_at)"; List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { Map row = new LinkedHashMap<>(); row.put("voucherNo", rs.getLong("voucher_no")); row.put("acNo", rs.getString("ac_no")); row.put("acName", rs.getString("ac_name")); + row.put("txnDate", rs.getString("txn_date")); + row.put("txnTime", rs.getString("txn_time")); + row.put("tellerName", rs.getString("teller_name")); + row.put("paymentType", rs.getString("payment_type")); + row.put("txnSource", rs.getString("txn_source")); row.put("dr", rs.getBigDecimal("dr_amount")); row.put("cr", rs.getBigDecimal("cr_amount")); return row; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java new file mode 100644 index 000000000..57e7d361a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java @@ -0,0 +1,159 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.cashtransfer.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomination; +import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenominationRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Records cashier denomination breakdown for GL account transactions (journal entries). + * Since journal entries API does not accept denominationBreakdown, this endpoint + * saves denomination data to m_savings_txn_denomination so the cashier position + * calculation includes GL cash movements. + * + * POST /cashier/gl-denomination + * Body: { txnId, accountNo, txnType ("DEPOSIT"/"WITHDRAWAL"), txnDate (yyyy-MM-dd), + * denominationBreakdown: [{ denominationId, denominationValue, count }] } + */ +@Path("/v1/cashier/gl-denomination") +@Component +@Tag(name = "Cashier GL Denomination") +@RequiredArgsConstructor +public class CashierGlDenominationApiResource { + + private final PlatformSecurityContext context; + private final SavingsTxnDenominationRepository savingsTxnDenominationRepository; + private final FromJsonHelper fromJsonHelper; + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Transactional + public String recordGlDenomination(final String body) { + final JsonElement root = fromJsonHelper.parse(body); + + // txnId may be a hex string (journal entry) or numeric (savings txn) + // Hex IDs (e.g. "a1fd0749a8a9") are parsed as base-16 Long so each GL + // transaction gets a unique txn_id — required for grouping in closing report + final String txnIdRaw = fromJsonHelper.extractStringNamed("txnId", root); + Long txnId = null; + if (txnIdRaw != null && !txnIdRaw.isBlank()) { + try { + txnId = Long.valueOf(txnIdRaw); + } catch (final NumberFormatException ignored) { + try { + final String hex = txnIdRaw.length() > 15 ? txnIdRaw.substring(txnIdRaw.length() - 15) : txnIdRaw; + txnId = Long.parseUnsignedLong(hex, 16); + } catch (final NumberFormatException ignored2) { + txnId = null; + } + } + } + final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root); + final String accountName = fromJsonHelper.extractStringNamed("accountName", root); + final String txnType = fromJsonHelper.extractStringNamed("txnType", root); + final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root); + final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root); + final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank()) + ? LocalDate.parse(txnDateStr) + : LocalDate.now(); + + if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) { + return "{\"result\":\"skipped\"}"; + } + + final JsonArray breakdown = fromJsonHelper.extractJsonArrayNamed("denominationBreakdown", root); + if (breakdown == null || breakdown.size() == 0) { + return "{\"result\":\"skipped\"}"; + } + + Long officerId = null; + String officerUsername = null; + try { + final AppUser user = this.context.authenticatedUser(); + if (user != null) { + officerId = user.getId(); + officerUsername = user.getUsername(); + } + } catch (final Exception ignored) {} + + final String resolvedTxnType = (txnType != null && !txnType.isBlank()) + ? txnType.toUpperCase() + : "DEPOSIT"; + + for (final JsonElement entry : breakdown) { + final JsonObject obj = entry.getAsJsonObject(); + if (!obj.has("denominationId") || obj.get("denominationId").isJsonNull() + || !obj.has("count")) { + continue; + } + final int count = obj.get("count").getAsInt(); + if (count <= 0) { + continue; + } + + final long denominationId = obj.get("denominationId").getAsLong(); + int denominationValue = 0; + if (obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull()) { + denominationValue = obj.get("denominationValue").getAsInt(); + } + final BigDecimal lineTotal = BigDecimal.valueOf((long) denominationValue * count); + + final SavingsTxnDenomination row = SavingsTxnDenomination.builder() + .txnId(txnId) + .savingsAccountId(null) // GL transaction — no savings account + .accountNo(accountNo) + .clientId(null) + .cifNo(accountName) // Store GL account name for closing report display + .denominationId(denominationId) + .denominationValue(denominationValue) + .count(count) + .totalAmount(lineTotal) + .txnDate(txnDate) + .createdAt(LocalDateTime.now()) + .txnType(resolvedTxnType) + .officerId(officerId) + .officerUsername(officerUsername) + .paymentType(paymentTypeName) + .build(); + + this.savingsTxnDenominationRepository.save(row); + } + + return "{\"result\":\"success\"}"; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/SavingsTxnDenomination.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/SavingsTxnDenomination.java index fedd02652..07710fbc5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/SavingsTxnDenomination.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/domain/SavingsTxnDenomination.java @@ -46,10 +46,10 @@ public class SavingsTxnDenomination { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "txn_id", nullable = false) + @Column(name = "txn_id") private Long txnId; - @Column(name = "savings_account_id", nullable = false) + @Column(name = "savings_account_id") private Long savingsAccountId; @Column(name = "account_no", length = 50) @@ -90,4 +90,8 @@ public class SavingsTxnDenomination { /** Username of the teller / officer who performed the transaction. */ @Column(name = "officer_username", length = 100) private String officerUsername; + + /** Payment mode name (Cash, Cheque, NEFT, etc.) — nullable for legacy rows. */ + @Column(name = "payment_type", length = 100) + private String paymentType; } 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 ef18922dd..2f2ba7c07 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 @@ -345,4 +345,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0317_grant_cashier_gl_loan_share_read_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0317_grant_cashier_gl_loan_share_read_permissions.xml index 846eff429..9882b0da6 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0317_grant_cashier_gl_loan_share_read_permissions.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0317_grant_cashier_gl_loan_share_read_permissions.xml @@ -46,4 +46,42 @@ + + + 9:b3a623604e792772bc89005772540108 + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + JOIN m_permission p ON p.code IN ( + 'CREATE_JOURNALENTRY', + 'READ_JOURNALENTRY', + 'READ_FINANCIALACTIVITYACCOUNT' + ) + WHERE r.name = 'CASHIER' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + DELETE FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'CASHIER') + AND permission_id IN ( + SELECT id FROM m_permission + WHERE code IN ( + 'CREATE_JOURNALENTRY', + 'READ_JOURNALENTRY', + 'READ_FINANCIALACTIVITYACCOUNT' + ) + ); + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0318_allow_nullable_savings_account_denomination.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0318_allow_nullable_savings_account_denomination.xml new file mode 100644 index 000000000..ee4ebf7f4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0318_allow_nullable_savings_account_denomination.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0319_allow_nullable_txn_id_denomination.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0319_allow_nullable_txn_id_denomination.xml new file mode 100644 index 000000000..3652f8b09 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0319_allow_nullable_txn_id_denomination.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0320_add_payment_type_to_denomination.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0320_add_payment_type_to_denomination.xml new file mode 100644 index 000000000..86bbe87b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0320_add_payment_type_to_denomination.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + +