feat(savings,cashier): maker-checker cash closure with vouchers and rounding
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

Closing a savings account paid real cash out of a teller's drawer in one
click, with no maker-checker, no cash-position tracking, and no way to hand
over a paisa balance (no paisa coins circulate, and the denomination writer
demands an exact match). This adds a Clerk -> Manager -> Cashier flow for
Cash closures, modelled on the existing Clerk Withdrawal Request.

New ClerkClosureRequestApiResource:
 - submit: Cash only, token required, guards ACTIVE status and open requests
 - approve: runs Fineract's own close command (pro-rata interest and the real
   GL withdrawal post here, atomically), reads the posted transaction back,
   rounds the payout to whole rupees and posts the difference to a new
   Round Off Account. A nil-balance closure goes straight to CLOSED rather
   than parking forever in a cashier queue with no cash to hand over.
 - disburse: cashier records the denomination breakdown against the rounded
   figure and the account closes
 - preview: dry-runs the very same interest posting close() performs, inside
   a REQUIRES_NEW transaction that is rolled back, so the figure shown to the
   clerk matches the real payout to the paisa. Reading the stored accrual
   instead gave a days-stale number, and CALCULATEINTEREST is not equivalent:
   it uses MathContext(15) and no postInterestOnDate, where close() uses
   MathContext(10) and feeds the closing date in as a posting-period
   boundary, which changes how many per-period roundings happen.

Vouchers: close() reaches handleWithdrawal directly rather than through the
withdrawal command handler, so the handler's own raiseSavingsVoucher never
ran and a closure paid cash out with nothing in the voucher register. One is
now raised at disbursement -- when the cash actually crosses the counter, in
the cashier's name -- with three lines that match the posted GL exactly:
Dr Savings Control + Dr Round Off = Cr Cash.

Cash direction: the rounding adjustment debited Cash where cash leaving the
drawer is a credit, leaving the cash GL disagreeing with what was physically
handed over. Both legs corrected.

Dates: closure_date, closedOnDate, the preview and the rounding entry now all
use the CBS business date, so preview and approval cannot land on different
days across a day roll.

Day Book: closures were absent entirely (it reads the request registers, not
m_voucher). Added as a fourth register, restricted to CLOSED rows and dated
on disbursed_at, since a closure only becomes a cash-book event at hand-over.
Its terminal status CLOSED was also missing from the totals gate, so a
completed closure moved real money out without appearing in the DR total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:20:08 +05:30
co-authored by Claude Opus 5
parent c4a99fea5d
commit fd91da3d76
8 changed files with 1218 additions and 11 deletions
@@ -94,6 +94,34 @@ public class CashReportApiResource {
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Account closures — a full-balance payout, so the debit side. Its own register rather than
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
// close() computes at approval) and the cash handed over is the ROUNDED
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
// paisa-exact closing_amount posted to the member's ledger.
//
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
// put rows with no cash behind them (a rejected closure has no amount at all) into the
// teller's day book. And the day they belong to is the day the cash left, so this filters on
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
// step to reach.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
'SAVINGS' AS account_type, r.client_name AS party,
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
r.payment_type_name, r.description, r.status,
COALESCE(r.disbursed_by, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_closure_request r
""" + verifyJoin + """
WHERE r.status = 'CLOSED'
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
@@ -128,13 +156,15 @@ public class CashReportApiResource {
filtered.add(row);
// Only approved money is real money: a pending or rejected voucher must not inflate the
// day's totals, even though it still belongs in the listing. The three source registers
// don't share one terminal status string: deposits and disbursements land on APPROVED,
// but a clerk withdrawal's own finished state is WITHDRAWN -- missing it here meant a
// completed withdrawal moved real money out yet never showed up in the DR total at all.
// day's totals, even though it still belongs in the listing. The source registers don't
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
// any of them meant a completed transaction moved real money out yet never showed up in
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
// both states cannot double-count it; only 'CLOSED' is unique to that register.
final String rowStatus = (String) row.get("status");
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus)) {
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
@@ -833,6 +863,12 @@ public class CashReportApiResource {
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
// (the column records which way cash moved), but its request lives in its own
// register -- without this join the closure's real voucher number is never found and
// the report falls back to showing the bare savings-transaction id instead.
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ officerFilter
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
@@ -853,7 +889,8 @@ public class CashReportApiResource {
// 2. Paged data query
final String dataSql = "SELECT "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, "
@@ -850,9 +850,9 @@ public class ClerkWithdrawalRequestApiResource {
/**
* Ensures the token number is unique for today's date among non-rejected requests, checking
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
* {@code excludeId} is set during resubmit to skip the record being edited.
* this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} --
* all three are redeemed at the very same Transaction Entry token field, so none of the pools
* may ever collide. {@code excludeId} is set during resubmit to skip the record being edited.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder(
@@ -867,13 +867,19 @@ public class ClerkWithdrawalRequestApiResource {
final long ownCount = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L);
final long otherCount = Optional.ofNullable(
final long dividendCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + otherCount > 0) {
final long closureCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_clerk_closure_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + dividendCount + closureCount > 0) {
throw apiError(400,
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
@@ -63,6 +63,7 @@ public class VoucherService {
public static final String TYPE_REPAYMENT = "REPAYMENT";
public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING";
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
@@ -51,4 +51,7 @@
<include file="parts/0522_seed_opening_balance_contra_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_seed_basoda_bank_chart_of_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0524_seed_basoda_bank_income_expense_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0525_create_clerk_closure_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0526_seed_round_off_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0527_clerk_closure_request_voucher_no.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Maker-checker savings account closure, mirroring m_clerk_withdrawal_request's shape: a
clerk submits a Cash closure request against an ACTIVE account (no amount: the real
payout, including pro-rata interest and TDS, is only known once a manager approves it and
Fineract's own close command runs). closing_amount is the exact, paisa-correct payout;
cashier_disbursed_amount is that figure rounded to the nearest whole rupee for physical
cash hand-over, with the difference absorbed by the Round Off Account. -->
<changeSet id="0525_create_clerk_closure_request" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_clerk_closure_request"/>
</not>
</preConditions>
<createTable tableName="m_clerk_closure_request">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(255)"/>
<column name="closure_date" type="DATE"/>
<column name="payment_type_id" type="INTEGER"/>
<column name="payment_type_name" type="VARCHAR(100)"/>
<column name="description" type="TEXT"/>
<column name="tok_no" type="VARCHAR(50)"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT"/>
<column name="clerk_name" type="VARCHAR(255)"/>
<column name="clerk_username" type="VARCHAR(100)"/>
<column name="manager_id" type="BIGINT"/>
<column name="manager_name" type="VARCHAR(255)"/>
<column name="manager_username" type="VARCHAR(100)"/>
<column name="rejection_reason" type="TEXT"/>
<column name="txn_id" type="VARCHAR(100)"/>
<column name="closing_amount" type="DECIMAL(19,6)"/>
<column name="rounding_amount" type="DECIMAL(19,6)"/>
<column name="cashier_disbursed_amount" type="DECIMAL(19,6)"/>
<column name="cashier_id" type="BIGINT"/>
<column name="disbursed_by" type="VARCHAR(150)"/>
<column name="disbursed_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
<column name="disbursement_denomination_data" type="TEXT"/>
<column name="activity_log" type="TEXT"/>
<column name="submitted_at" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
</createTable>
<createIndex indexName="idx_ccr_status" tableName="m_clerk_closure_request">
<column name="status"/>
</createIndex>
<createIndex indexName="idx_ccr_savings_account_id" tableName="m_clerk_closure_request">
<column name="savings_account_id"/>
</createIndex>
<createIndex indexName="idx_ccr_clerk_id" tableName="m_clerk_closure_request">
<column name="clerk_id"/>
</createIndex>
<createIndex indexName="idx_ccr_submitted_at" tableName="m_clerk_closure_request">
<column name="submitted_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Cash closure of a savings account pays out a whole-rupee amount (no paisa coins are in
circulation), but the account's real, interest-inclusive balance is exact to the paisa.
The few paise of difference are absorbed here rather than on the customer's own ledger:
standard practice at every real bank. Looked up directly by gl_code in
ClerkClosureRequestApiResource, not via acc_gl_financial_activity_account (round-off isn't
one of Fineract's fixed financial-activity types). -->
<changeSet id="0526_seed_round_off_account" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '501200'</sqlCheck>
</preConditions>
<sql>
INSERT INTO acc_gl_account
(name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum)
VALUES ('Round Off Account', '501200', false, true, 1, 5);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- A cash closure pays real money out of a teller's drawer, so it has to appear in the central
voucher register like every other counter transaction does. The voucher is raised at manager
approval (the amount is not known before that) and reattributed to the cashier who actually
hands the cash over: see ClerkClosureRequestApiResource#approve and #disburse. -->
<changeSet id="0527_clerk_closure_request_voucher_no" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_closure_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_closure_request">
<column name="voucher_no" type="VARCHAR(30)"/>
</addColumn>
</changeSet>
</databaseChangeLog>