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:
co-authored by
Claude Sonnet 4.6
parent
53b6a94bf8
commit
19639c9e02
+13
-4
@@ -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);
|
||||
}
|
||||
|
||||
+14
-4
@@ -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;
|
||||
|
||||
+159
@@ -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\"}";
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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>
|
||||
|
||||
+38
@@ -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>
|
||||
|
||||
+23
@@ -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>
|
||||
+23
@@ -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>
|
||||
+20
@@ -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>
|
||||
Reference in New Issue
Block a user