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
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:
+59
-10
@@ -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>
|
||||
|
||||
+22
@@ -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>
|
||||
Reference in New Issue
Block a user