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("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
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.closed_by_name, r.closed_at, r.closing_cash,
|
||||||
r.book_cash, r.cash_difference,
|
r.book_cash, r.cash_difference,
|
||||||
r.force_closed, r.force_close_reason, r.remarks
|
r.force_closed, r.force_close_reason, r.remarks
|
||||||
@@ -116,7 +116,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
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.closed_by_name, r.closed_at, r.closing_cash,
|
||||||
r.book_cash, r.cash_difference,
|
r.book_cash, r.cash_difference,
|
||||||
r.force_closed, r.force_close_reason, r.remarks
|
r.force_closed, r.force_close_reason, r.remarks
|
||||||
@@ -208,6 +208,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
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
|
// 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.
|
// 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
|
final List<Map<String, Object>> effectiveDenominations = !denominations.isEmpty() ? denominations
|
||||||
: (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of());
|
: (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of());
|
||||||
final BigDecimal denominationTotal = sumDenominations(effectiveDenominations);
|
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
|
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("""
|
final Long id = jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_branch_day_register
|
INSERT INTO m_branch_day_register
|
||||||
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
|
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed,
|
||||||
VALUES (?,?,?,?,?,?,false)
|
opening_difference_reason)
|
||||||
|
VALUES (?,?,?,?,?,?,false,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
|
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);
|
saveDenominationDetail(id, "OPENING", effectiveDenominations);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
|
final Map<String, Object> res = new LinkedHashMap<>();
|
||||||
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
|
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 ──────────────────────────────────────────────
|
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
||||||
@@ -468,6 +492,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
m.put("openedByName", r.get("opened_by_name"));
|
m.put("openedByName", r.get("opened_by_name"));
|
||||||
m.put("openedAt", str(r.get("opened_at")));
|
m.put("openedAt", str(r.get("opened_at")));
|
||||||
m.put("openingCash", r.get("opening_cash"));
|
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("closedByName", r.get("closed_by_name"));
|
||||||
m.put("closedAt", str(r.get("closed_at")));
|
m.put("closedAt", str(r.get("closed_at")));
|
||||||
m.put("closingCash", r.get("closing_cash"));
|
m.put("closingCash", r.get("closing_cash"));
|
||||||
@@ -482,14 +507,27 @@ public class BranchDayRegisterApiResource {
|
|||||||
return out;
|
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) {
|
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. */
|
/** The branch's currently open day, or null when none is open. */
|
||||||
private Map<String, Object> findOpenDay(final Long officeId) {
|
private Map<String, Object> findOpenDay(final Long officeId) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
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
|
FROM m_branch_day_register
|
||||||
WHERE office_id = ? AND closed_at IS NULL
|
WHERE office_id = ? AND closed_at IS NULL
|
||||||
ORDER BY business_date ASC LIMIT 1
|
ORDER BY business_date ASC LIMIT 1
|
||||||
@@ -504,9 +542,20 @@ public class BranchDayRegisterApiResource {
|
|||||||
m.put("openedByName", r.get("opened_by_name"));
|
m.put("openedByName", r.get("opened_by_name"));
|
||||||
m.put("openedAt", str(r.get("opened_at")));
|
m.put("openedAt", str(r.get("opened_at")));
|
||||||
m.put("openingCash", r.get("opening_cash"));
|
m.put("openingCash", r.get("opening_cash"));
|
||||||
|
m.put("openingDifferenceReason", r.get("opening_difference_reason"));
|
||||||
return m;
|
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. */
|
/** The most recent closing figure before this date, so it carries forward as the opening. */
|
||||||
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
|
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
|
||||||
final List<BigDecimal> rows = jdbcTemplate.query("""
|
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/0512_expand_institution_and_branch_identifiers.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0513_institution_profile_full_bank_master.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/0514_grant_teller_roles_read_clientnominee.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0515_branch_day_register_opening_difference_reason.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</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