From 8d224a22b412af4c4e11df007eaece3c66206a8c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sat, 29 Aug 2026 11:01:30 +0530 Subject: [PATCH] fix(branchday): correct timestamp serialization, require a reason for an opening cash mismatch 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 --- .../api/BranchDayRegisterApiResource.java | 69 ++++++++++++++++--- .../tenant/final-changelog-tenant.xml | 1 + ...day_register_opening_difference_reason.xml | 22 ++++++ 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 4810abbac..29977ff75 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -80,7 +80,7 @@ public class BranchDayRegisterApiResource { final List> 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> 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> 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> 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 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 findOpenDay(final Long officeId) { final List> 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 rows = jdbcTemplate.query(""" diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index b238153fb..fff9dac05 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -38,4 +38,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml new file mode 100644 index 000000000..fab11835d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + +