fix(branchday): correct timestamp serialization, require a reason for an opening cash mismatch
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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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 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 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 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-1) (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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 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 - MariaDB / test (test-core-1) (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
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 E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s

Opened-by/closed-by times on the day register were rendering blank: the
JDBC Timestamp's own toString() separates date and time with a space, not
the ISO-8601 'T' every frontend consumer parses on. str() now formats a
Timestamp explicitly instead of falling through to the generic toString().

Opening a business day with a note/coin count or lump sum that did not
match the branch's own closing figure from the day before was previously
accepted with no record of why -- every downstream figure (vault position,
EOD summary, the next day's own carry-forward) would then be built on an
unexplained short or excess baseline with nothing to explain it later. The
open endpoint now requires an openingDifferenceReason whenever the caller
supplies a figure that differs from the prior close, enforced server-side
so it can't be bypassed by calling the API directly; a day with no prior
close, or one left to the automatic carry-forward untouched, is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:01:30 +05:30
co-authored by Claude Sonnet 5
parent f746a1fb2c
commit 8d224a22b4
3 changed files with 82 additions and 10 deletions
@@ -80,7 +80,7 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.opened_by_name, r.opened_at, r.opening_cash, r.opening_difference_reason,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
@@ -116,7 +116,7 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.opened_by_name, r.opened_at, r.opening_cash, r.opening_difference_reason,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
@@ -208,6 +208,7 @@ public class BranchDayRegisterApiResource {
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
// A branch runs one day at a time. Opening a second while the first is still open is how a
// day gets stranded, so the previous one has to be closed first.
@@ -239,22 +240,45 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> effectiveDenominations = !denominations.isEmpty() ? denominations
: (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of());
final BigDecimal denominationTotal = sumDenominations(effectiveDenominations);
final boolean hasPreviousClose = hasPreviousClose(officeId, businessDate);
final BigDecimal previousClosingCash = hasPreviousClose ? lastClosingCash(officeId, businessDate) : null;
final BigDecimal carried = denominationTotal != null ? denominationTotal
: (openingCash != null ? openingCash : lastClosingCash(officeId, businessDate));
: (openingCash != null ? openingCash : (hasPreviousClose ? previousClosingCash : BigDecimal.ZERO));
// A figure the caller actually chose (typed a lump sum, or counted denominations) that does
// not match what the branch closed with yesterday has to say why. Left unchecked, a short or
// excess opening count is recorded with nothing explaining it, and every figure calculated
// from this day onward (vault position, EOD summary, tomorrow's own carry-forward) inherits
// that unexplained gap silently. A day left to the automatic carry-forward (no figure
// supplied at all) never triggers this — there is no choice being made to justify.
final boolean callerSuppliedFigure = !denominations.isEmpty() || openingCash != null;
final boolean opensWithADifference = callerSuppliedFigure && hasPreviousClose && carried != null
&& carried.compareTo(previousClosingCash) != 0;
if (opensWithADifference && (openingDifferenceReason == null || openingDifferenceReason.isBlank())) {
throw new PlatformDataIntegrityException("error.msg.branchday.opening.difference.reason.required",
"Opening cash (Rs. " + carried + ") differs from yesterday's closing cash (Rs. "
+ previousClosingCash + "). Record why before opening.");
}
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_branch_day_register
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
VALUES (?,?,?,?,?,?,false)
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed,
opening_difference_reason)
VALUES (?,?,?,?,?,?,false,?)
RETURNING id
""",
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
Timestamp.from(Instant.now()), carried);
Timestamp.from(Instant.now()), carried, opensWithADifference ? openingDifferenceReason.trim() : null);
saveDenominationDetail(id, "OPENING", effectiveDenominations);
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
final Map<String, Object> res = new LinkedHashMap<>();
res.put("resourceId", id);
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("openingCash", carried == null ? BigDecimal.ZERO : carried);
res.put("status", "OPEN");
return gson.toJson(res);
}
// ── POST /v1/branchday/close ──────────────────────────────────────────────
@@ -468,6 +492,7 @@ public class BranchDayRegisterApiResource {
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
m.put("openingDifferenceReason", r.get("opening_difference_reason"));
m.put("closedByName", r.get("closed_by_name"));
m.put("closedAt", str(r.get("closed_at")));
m.put("closingCash", r.get("closing_cash"));
@@ -482,14 +507,27 @@ public class BranchDayRegisterApiResource {
return out;
}
/**
* {@link Timestamp#toString()} separates the date and time with a space ("2026-08-28
* 08:49:36.29679"), not the 'T' ISO-8601 uses — every consumer of these fields (the frontend's
* split-on-'T' time parsing, most of all) expects ISO-8601, so a plain Timestamp needs its own
* formatting rather than falling through to the generic toString().
*/
private static String str(final Object o) {
return o == null ? null : o.toString();
if (o == null) {
return null;
}
if (o instanceof Timestamp) {
return ((Timestamp) o).toLocalDateTime().toString();
}
return o.toString();
}
/** The branch's currently open day, or null when none is open. */
private Map<String, Object> findOpenDay(final Long officeId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash
SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash,
opening_difference_reason
FROM m_branch_day_register
WHERE office_id = ? AND closed_at IS NULL
ORDER BY business_date ASC LIMIT 1
@@ -504,9 +542,20 @@ public class BranchDayRegisterApiResource {
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
m.put("openingDifferenceReason", r.get("opening_difference_reason"));
return m;
}
/** Whether this office has ever closed a business day before this date — the gate on the
* opening-difference check, since there is nothing to differ from on a branch's very first day. */
private boolean hasPreviousClose(final Long officeId, final LocalDate before) {
final Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_branch_day_register
WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
""", Integer.class, officeId, Date.valueOf(before));
return count != null && count > 0;
}
/** The most recent closing figure before this date, so it carries forward as the opening. */
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
final List<BigDecimal> rows = jdbcTemplate.query("""
@@ -38,4 +38,5 @@
<include file="parts/0512_expand_institution_and_branch_identifiers.xml" relativeToChangelogFile="true"/>
<include file="parts/0513_institution_profile_full_bank_master.xml" relativeToChangelogFile="true"/>
<include file="parts/0514_grant_teller_roles_read_clientnominee.xml" relativeToChangelogFile="true"/>
<include file="parts/0515_branch_day_register_opening_difference_reason.xml" relativeToChangelogFile="true"/>
</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">
<!-- Opening a business day with a note/coin count (or a plain opening cash figure) that does not
match what the branch actually closed with the day before was previously accepted silently -
nothing recorded why, and every downstream figure (vault position, EOD summary, tomorrow's
own carry-forward) would then be built on an unexplained short or excess baseline. This
column is where that reason is kept once the API starts requiring one. -->
<changeSet id="0515_add_opening_difference_reason" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_branch_day_register" columnName="opening_difference_reason"/></not>
</preConditions>
<addColumn tableName="m_branch_day_register">
<column name="opening_difference_reason" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>