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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-10 15:31:12 +05:30
co-authored by Claude Sonnet 4.6
parent 53b6a94bf8
commit 19639c9e02
9 changed files with 299 additions and 10 deletions
@@ -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);
}
@@ -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<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
Map<String, Object> 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;
@@ -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\"}";
}
}
@@ -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;
}
@@ -345,4 +345,7 @@
<include file="parts/0315_grant_cashier_transaction_entry_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0316_create_clerk_role_and_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0317_grant_cashier_gl_loan_share_read_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0318_allow_nullable_savings_account_denomination.xml" relativeToChangelogFile="true"/>
<include file="parts/0319_allow_nullable_txn_id_denomination.xml" relativeToChangelogFile="true"/>
<include file="parts/0320_add_payment_type_to_denomination.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -46,4 +46,42 @@
</rollback>
</changeSet>
<!--
Grant journal entry and financial activity permissions to CASHIER role
for GL transactions posted via the Cashier Transaction Entry screen.
- CREATE_JOURNALENTRY : Post manual journal entries (Dr/Cr GL accounts)
- READ_JOURNALENTRY : Read journal entries for confirmation/history
- READ_FINANCIALACTIVITYACCOUNT: Read financial activity → GL mappings (cash vault lookup)
-->
<changeSet id="0317b_grant_cashier_journal_entry_permissions" author="fineract">
<validCheckSum>9:b3a623604e792772bc89005772540108</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code IN (
'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
);
</sql>
<rollback>
DELETE FROM m_role_permission
WHERE role_id = (SELECT id FROM m_role WHERE name = 'CASHIER')
AND permission_id IN (
SELECT id FROM m_permission
WHERE code IN (
'CREATE_JOURNALENTRY',
'READ_JOURNALENTRY',
'READ_FINANCIALACTIVITYACCOUNT'
)
);
</rollback>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
for licensing details.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Allow savings_account_id to be NULL in m_savings_txn_denomination so that
GL account transactions (journal entries) can also record denomination
breakdown without a savings account reference.
-->
<changeSet id="0318_allow_nullable_savings_account_denomination" author="fineract">
<dropNotNullConstraint tableName="m_savings_txn_denomination"
columnName="savings_account_id"
columnDataType="BIGINT"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
for licensing details.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Allow txn_id to be NULL in m_savings_txn_denomination so that
GL account transactions (journal entries with hex IDs) can also
record denomination breakdown without a numeric transaction ID.
-->
<changeSet id="0319_allow_nullable_txn_id_denomination" author="fineract">
<dropNotNullConstraint tableName="m_savings_txn_denomination"
columnName="txn_id"
columnDataType="BIGINT"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
for licensing details.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0320_add_payment_type_to_denomination" author="fineract">
<addColumn tableName="m_savings_txn_denomination">
<column name="payment_type" type="VARCHAR(100)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>