feat(cashier,accounting): denomination-aware vault controls, Bank/Branch Master, Fund Transfer request type
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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (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 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 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 2 of 10) (push) Canceled after 0s

Closes the gap where a vault could hand out more cash (in total or in a
specific note/coin) than it actually held: CashTransferApiResource now
validates against the branch day's opening denomination breakdown plus
today's settlements/transfers, and blocks entirely when no business day is
open. BranchDayRegisterApiResource gains a real denomination breakdown per
open/close leg (migration 0510), carried forward automatically day to day,
and its book-cash formula no longer double-counts vault-to-cashier transfers
as new cash in. CashReportApiResource adds a vault-position report (the
teller/vault analogue of cashier-position) and fixes an off-by-one date
comparison that excluded same-day transfers from a vault's anchor position.

Institution/Branch Master: new InstitutionProfileApiResource and
BranchProfileApiResource (migrations 0511-0513) give the bank a real
"Bank Master" profile (identity, regulatory details, address, SWIFT/BIC) and
each office its own branch-level fields (code, IFSC/MICR, working hours,
geo-location) as side tables on m_office rather than touching the stock
Fineract office entity. ClearingGlConfigApiResource exposes the previously
config-only-via-SQL m_clearing_gl_config mapping (CTS/NACH/RTGS suspense
accounts) as a proper admin API.

ClerkDisbursementRequestApiResource now accepts a FUND_TRANSFER request type
alongside DISBURSEMENT/COLLECTION/BANK_TRANSFER, and migration 0514 grants
the teller-facing roles READ_CLIENTNOMINEE, which they were missing entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:36:59 +05:30
co-authored by Claude Sonnet 5
parent 1bc9d0901d
commit f746a1fb2c
13 changed files with 1351 additions and 6 deletions
@@ -207,6 +207,7 @@ public class BranchDayRegisterApiResource {
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
// 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.
@@ -229,9 +230,17 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " has already been opened and closed for this branch.");
}
// Yesterday's closing is today's opening unless the caller states otherwise, so the carried
// figure does not have to be retyped and cannot drift.
final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate);
// A denomination breakdown given by the caller is the true source of the opening figure —
// its own total overrides a plain openingCash, the same way physically counting the drawer
// overrides whatever figure was expected. Without one, fall back to yesterday's own closing
// breakdown (if it recorded one) so the note/coin stock stays traceable day to day instead
// of resetting to "unknown" the moment nobody re-enters it; only when neither exists does
// this day open with a lump sum and no denomination detail at all.
final List<Map<String, Object>> effectiveDenominations = !denominations.isEmpty() ? denominations
: (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of());
final BigDecimal denominationTotal = sumDenominations(effectiveDenominations);
final BigDecimal carried = denominationTotal != null ? denominationTotal
: (openingCash != null ? openingCash : lastClosingCash(officeId, businessDate));
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_branch_day_register
@@ -242,6 +251,8 @@ public class BranchDayRegisterApiResource {
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
Timestamp.from(Instant.now()), carried);
saveDenominationDetail(id, "OPENING", effectiveDenominations);
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
}
@@ -262,7 +273,12 @@ public class BranchDayRegisterApiResource {
throw new PlatformDataIntegrityException("error.msg.branchday.date.required",
"Which business day is being closed?");
}
final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final BigDecimal denominationTotal = sumDenominations(denominations);
// A physical count at close is the same override relationship as at open: what was actually
// counted note-by-note wins over a typed-in total.
final BigDecimal closingCash = denominationTotal != null ? denominationTotal
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
@@ -300,6 +316,9 @@ public class BranchDayRegisterApiResource {
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
officeId, Date.valueOf(businessDate));
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
@@ -311,8 +330,133 @@ public class BranchDayRegisterApiResource {
return gson.toJson(res);
}
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
/**
* The opening and closing denomination breakdown for one business day, if one was ever
* recorded — a day opened or closed with a plain typed-in amount and no breakdown returns an
* empty list for that leg rather than an error, since not every day is guaranteed to carry one.
*/
@GET
@Path("denominations")
public String denominations(@QueryParam("officeId") final Long officeIdParam,
@QueryParam("businessDate") final String businessDateParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
? LocalDate.parse(businessDateParam)
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<Long> registerIds = jdbcTemplate.queryForList(
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
Long.class, officeId, Date.valueOf(businessDate));
if (registerIds.isEmpty()) {
return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(),
"opening", List.of(), "closing", List.of()));
}
final Long registerId = registerIds.get(0);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT leg, denomination_id, denomination_value, count, total_amount
FROM m_branch_day_register_detail
WHERE register_id = ?
ORDER BY denomination_value DESC
""", registerId);
final List<Map<String, Object>> opening = new ArrayList<>();
final List<Map<String, Object>> closing = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("denominationId", r.get("denomination_id"));
m.put("denominationValue", r.get("denomination_value"));
m.put("count", r.get("count"));
m.put("totalAmount", r.get("total_amount"));
if ("OPENING".equals(r.get("leg"))) {
opening.add(m);
} else {
closing.add(m);
}
}
return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(),
"opening", opening, "closing", closing));
}
// ── Helpers ───────────────────────────────────────────────────────────────
/** Reads a denominations[] array of {denominationId, denominationValue, count} into plain maps. */
private List<Map<String, Object>> extractDenominations(final com.google.gson.JsonElement root, final String field) {
if (!this.fromJsonHelper.parameterExists(field, root)) {
return List.of();
}
final com.google.gson.JsonArray array = this.fromJsonHelper.extractJsonArrayNamed(field, root);
if (array == null) {
return List.of();
}
final List<Map<String, Object>> out = new ArrayList<>();
for (final com.google.gson.JsonElement el : array) {
final com.google.gson.JsonObject obj = el.getAsJsonObject();
final int count = obj.has("count") && !obj.get("count").isJsonNull() ? obj.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", obj.has("denominationId") && !obj.get("denominationId").isJsonNull()
? obj.get("denominationId").getAsLong() : null);
row.put("denominationValue", obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull()
? obj.get("denominationValue").getAsInt() : 0);
row.put("count", count);
out.add(row);
}
return out;
}
private BigDecimal sumDenominations(final List<Map<String, Object>> denominations) {
if (denominations == null || denominations.isEmpty()) {
return null;
}
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> row : denominations) {
final int value = (int) row.get("denominationValue");
final int count = (int) row.get("count");
total = total.add(BigDecimal.valueOf((long) value * count));
}
return total;
}
private void saveDenominationDetail(final Long registerId, final String leg, final List<Map<String, Object>> denominations) {
if (denominations == null || denominations.isEmpty()) {
return;
}
for (final Map<String, Object> row : denominations) {
final int value = (int) row.get("denominationValue");
final int count = (int) row.get("count");
jdbcTemplate.update("""
INSERT INTO m_branch_day_register_detail
(register_id, leg, denomination_id, denomination_value, count, total_amount)
VALUES (?,?,?,?,?,?)
""", registerId, leg, row.get("denominationId"), value, count,
BigDecimal.valueOf((long) value * count));
}
}
/** Yesterday's own closing breakdown, if it recorded one — the denomination-level analogue of lastClosingCash. */
private List<Map<String, Object>> lastClosingDenominations(final Long officeId, final LocalDate before) {
final List<Long> registerIds = jdbcTemplate.queryForList("""
SELECT id FROM m_branch_day_register
WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
ORDER BY business_date DESC LIMIT 1
""", Long.class, officeId, Date.valueOf(before));
if (registerIds.isEmpty()) {
return List.of();
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT denomination_id AS "denominationId", denomination_value AS "denominationValue", count
FROM m_branch_day_register_detail
WHERE register_id = ? AND leg = 'CLOSING'
""", registerIds.get(0));
return rows;
}
private List<Map<String, Object>> mapDays(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
@@ -378,6 +522,13 @@ public class BranchDayRegisterApiResource {
* approved cash deposits less what went out as approved cash withdrawals. Deliberately derived
* here and then stored on the row, so the figure the day was closed against survives any later
* reversal.
*
* Vault-to-cashier transfers and cashier-to-vault settlements are shown for information but are
* NOT part of the total: they move cash between the branch's own vault and its own cashiers, so
* the branch's overall physical cash is unchanged either way. Adding the transfer and
* subtracting the settlement (an earlier version of this method did both) double-counts the same
* rupee — once as the day's opening figure, again as "new" cash arriving via the transfer — and
* manufactures a phantom shortage equal to whatever was handed to a cashier that day.
*/
private Map<String, Object> bookBreakdown(final Long officeId, final LocalDate businessDate) {
final Date date = Date.valueOf(businessDate);
@@ -416,7 +567,7 @@ public class BranchDayRegisterApiResource {
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, date));
final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut);
final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("openingCash", opening);
@@ -533,6 +533,130 @@ public class CashReportApiResource {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
/**
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
* bank have", as opposed to cashier-position which answers the same question for a cashier.
* Anchored on the most recent business day (on or before asOfDate) whose branch day register
* recorded an OPENING denomination breakdown, then walked forward by everything settled back
* into this vault and handed out of it since. A vault whose opening days never recorded a
* breakdown (plain lump-sum opens only) has nothing to report per denomination — the response
* says so explicitly rather than silently returning zeros that look like an empty vault.
*/
@GET
@Path("vault-position")
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
@QueryParam("asOfDate") final String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
if (tellerId == null) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
final List<Map<String, Object>> all = new ArrayList<>();
for (final Long id : tellerIds) {
all.add(buildPositionForVault(id, dateFilter));
}
return gson.toJson(all);
}
return gson.toJson(buildPositionForVault(tellerId, dateFilter));
}
private Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
final Map<String, Object> result = new LinkedHashMap<>();
result.put("tellerId", tellerId);
result.put("asOfDate", dateFilter);
String tellerName = null;
Long officeId = null;
try {
final Map<String, Object> teller = jdbcTemplate.queryForMap(
"SELECT name, office_id FROM m_tellers WHERE id = ?", tellerId);
tellerName = (String) teller.get("name");
officeId = ((Number) teller.get("office_id")).longValue();
} catch (Exception ignored) {
// intentionally ignored
}
result.put("tellerName", tellerName);
if (officeId == null) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
// The anchor: the most recent register on or before asOfDate that actually recorded an
// opening breakdown. Its breakdown already reflects every prior day's carry-forward (see
// BranchDayRegisterApiResource#lastClosingDenominations), so only movements from that one
// anchor date forward need to be added on top of it.
final List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
WHERE d.register_id = r.id AND d.leg = 'OPENING')
ORDER BY r.business_date DESC LIMIT 1
""", officeId, dateFilter);
if (anchorRows.isEmpty()) {
result.put("hasBreakdown", false);
result.put("totalAmount", BigDecimal.ZERO);
result.put("denominations", new ArrayList<>());
return result;
}
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
final String anchorDate = anchorRows.get(0).get("business_date").toString();
final Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query("""
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
WHERE d.register_id = ? AND d.leg = 'OPENING'
""", DENOM_ROW, registerId), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query("""
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
final List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
.forEach(e -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("count", e.getValue()[2]);
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
final BigDecimal totalAmount = rows.stream()
.map(r -> (BigDecimal) r.get("amount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
result.put("hasBreakdown", true);
result.put("anchorDate", anchorDate);
result.put("totalAmount", totalAmount);
result.put("denominations", rows);
return result;
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -98,6 +98,8 @@ public class CashTransferApiResource {
"At least one denomination with a positive count is required.");
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
header.setFromParty(fromParty);
@@ -264,6 +266,127 @@ public class CashTransferApiResource {
return total;
}
/**
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
* branch's business day carries an opening_cash figure forward each day (see
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
* that opening figure, plus anything a cashier has already settled back into this same vault
* today, minus whatever this vault has already handed out to any cashier today. Reversed
* transfers/settlements don't count.
*
* No open business day for the vault's office means there is no opening_cash to check against, so
* the transfer is blocked outright rather than silently allowed against an unknown balance.
*/
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
final JsonArray denominations, final LocalDate transferDate) {
final Long officeId;
try {
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
"Vault/teller " + fromTellerId + " was not found.");
}
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
final List<Long> registerIds = jdbcTemplate.query("""
SELECT id FROM m_branch_day_register
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
if (registerIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
"The business day for this branch is not open for " + businessDate
+ ". Open the business day before allocating cash to a cashier.");
}
final Long registerId = registerIds.get(0);
final BigDecimal openingCash = jdbcTemplate.queryForObject(
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
if (requestedAmount.compareTo(available) > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
}
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
}
/**
* The total-amount check above can pass while a specific note/coin still runs short — a vault
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
* total is well within its balance. This checks each requested denomination against what the
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
* settlements-in minus today's transfers-out actually holds of that specific denomination.
*
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
* denomination against, so this is skipped rather than blocking every transfer on a day that
* simply never captured one.
*/
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
final JsonArray denominations, final java.sql.Date businessDate) {
final List<Object[]> openingRows = jdbcTemplate.query("""
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
WHERE register_id = ? AND leg = 'OPENING'
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
if (openingRows.isEmpty()) {
return;
}
final Map<Long, Integer> available = new java.util.HashMap<>();
for (final Object[] row : openingRows) {
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
}
final List<Object[]> settledRows = jdbcTemplate.query("""
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
GROUP BY csd.denomination_id
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
for (final Object[] row : settledRows) {
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
}
final List<Object[]> handedOutRows = jdbcTemplate.query("""
SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd
JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
GROUP BY ctd.denomination_id
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
for (final Object[] row : handedOutRows) {
available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum);
}
for (final JsonElement el : denominations) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
continue;
}
final long denominationId = item.get("denominationId").getAsLong();
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
final int availableCount = available.getOrDefault(denominationId, 0);
if (count > availableCount) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
+ ", requested " + count + ".");
}
}
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.clearing.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing
* role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are
* fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads
* them and updates which GL account one points at.
*/
@Path("/v1/clearingglconfig")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ClearingGlConfigApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code
FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id
ORDER BY c.config_key
""");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toResult(row));
}
return gson.toJson(out);
}
@GET
@Path("template")
public String template() {
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT id, name, gl_code FROM acc_gl_account
WHERE disabled = false AND manual_journal_entries_allowed = true
ORDER BY name
""");
final List<Map<String, Object>> options = new ArrayList<>();
for (final Map<String, Object> a : accounts) {
final Map<String, Object> o = new LinkedHashMap<>();
o.put("id", a.get("id"));
o.put("name", a.get("name"));
o.put("glCode", a.get("gl_code"));
options.add(o);
}
return gson.toJson(Map.of("glAccountOptions", options));
}
@PUT
@Path("{configKey}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("configKey") final String configKey, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG");
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?",
Integer.class, configKey);
if (exists == null || exists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found",
"Clearing GL config key '" + configKey + "' was not found.");
}
final var root = this.fromJsonHelper.parse(body);
final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root);
if (glAccountId != null) {
final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?",
Integer.class, glAccountId);
if (accountExists == null || accountExists == 0) {
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found",
"GL account " + glAccountId + " was not found.");
}
}
jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId,
configKey);
return retrieveAll();
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("id", row.get("id"));
out.put("configKey", row.get("config_key"));
out.put("description", row.get("description"));
out.put("glAccountId", row.get("gl_account_id"));
out.put("glAccountName", row.get("gl_account_name"));
out.put("glCode", row.get("gl_code"));
return out;
}
}
@@ -109,7 +109,7 @@ public class ClerkDisbursementRequestApiResource {
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
if (requestType == null || requestType.isBlank()
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
@@ -0,0 +1,186 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The operational profile behind a branch — code, address, contact, type and working hours — kept as
* a side table on m_office (see migration 0511) rather than added to the core office entity, so this
* stays independent of the stock office create/edit flow. Every office already exists via the
* standard Offices screen; this only adds the real-world fields on top for the offices that have them
* set. An office with no profile row yet is a valid, ordinary state — not an error.
*/
@Path("/v1/branchprofile")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BranchProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String list(@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission("BRANCHPROFILE");
final String where = officeId != null ? "WHERE o.id = ?" : "";
final List<Map<String, Object>> rows = officeId != null
? jdbcTemplate.queryForList(baseSql() + where + " ORDER BY o.hierarchy", officeId)
: jdbcTemplate.queryForList(baseSql() + " ORDER BY o.hierarchy");
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : rows) {
out.add(toResult(row));
}
if (officeId != null) {
if (out.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
return gson.toJson(out.get(0));
}
return gson.toJson(out);
}
@PUT
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("officeId") final Long officeId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
Integer.class, officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
final var root = this.fromJsonHelper.parse(body);
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
final String address = this.fromJsonHelper.extractStringNamed("address", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root);
final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root);
final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root);
final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root);
final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root);
final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root);
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return list(officeId);
}
private String baseSql() {
return """
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date,
p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email,
p.working_hours_start, p.working_hours_end, p.working_days,
p.ifsc_code, p.micr_code, p.latitude, p.longitude,
p.updated_by_username, p.updated_at
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
""";
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("officeId", row.get("office_id"));
out.put("officeName", row.get("office_name"));
out.put("hierarchy", row.get("hierarchy"));
out.put("parentId", row.get("parent_id"));
final Object opening = row.get("opening_date");
out.put("openingDate", opening == null ? null : opening.toString());
out.put("branchCode", row.get("branch_code"));
out.put("branchType", row.get("branch_type"));
out.put("address", row.get("address"));
out.put("contactPhone", row.get("contact_phone"));
out.put("contactEmail", row.get("contact_email"));
out.put("workingHoursStart", row.get("working_hours_start"));
out.put("workingHoursEnd", row.get("working_hours_end"));
out.put("workingDays", row.get("working_days"));
out.put("ifscCode", row.get("ifsc_code"));
out.put("micrCode", row.get("micr_code"));
out.put("latitude", row.get("latitude"));
out.put("longitude", row.get("longitude"));
out.put("updatedByUsername", row.get("updated_by_username"));
final Object updatedAt = row.get("updated_at");
out.put("updatedAt", updatedAt == null ? null : updatedAt.toString());
return out;
}
}
@@ -0,0 +1,274 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.orgsetup.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The institution's own "Bank Master" — identity and classification, regulatory details, address
* (registered, with optional head-office/communication overrides), contact, the institution-level
* banking identifier (SWIFT/BIC — IFSC/MICR are per-branch and live on {@link BranchProfileApiResource}
* instead), document header names and terms text, the authorised representative, and audit fields. A
* singleton (always id=1): unlike offices there is only ever one bank running this tenant.
*
* Deliberately does NOT store Business Date, Financial Year, Time Zone, Working Days or Holiday
* Calendar — those already exist as their own configuration elsewhere in the system, and copying
* them here would just create a second, driftable value for the same fact. The currency shown is
* likewise derived live from {@code m_organisation_currency} rather than stored.
*/
@Path("/v1/institutionprofile")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class InstitutionProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
@GET
public String get() {
this.context.authenticatedUser().validateHasReadPermission("INSTITUTIONPROFILE");
ensureRowExists();
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT bank_name, legal_name, short_name, bank_code, institution_code, bank_type,
ownership_type, parent_institution_name,
registration_number, license_number, established_date, status, country,
regulatory_authority, registration_date, license_status, license_expiry_date,
swift_bic,
address_line1, address_line2, city, district, state, pincode,
head_office_same_as_registered, head_office_address,
communication_same_as_registered, communication_address,
contact_phone, contact_email, customer_care_number, grievance_email, website,
pan, tan, gstin,
statement_header_name, passbook_header_name, receipt_header_name, terms_and_conditions,
authorized_person_name, authorized_person_designation, authorized_person_contact,
authorized_person_email,
created_by_username, created_at, activation_date, deactivation_date,
updated_by_username, updated_at
FROM m_institution_profile WHERE id = 1
""");
return gson.toJson(toResult(row));
}
@PUT
@Consumes({ MediaType.APPLICATION_JSON })
public String update(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
ensureRowExists();
final var root = this.fromJsonHelper.parse(body);
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root);
final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root);
final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root);
final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root);
final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root));
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String country = this.fromJsonHelper.extractStringNamed("country", root);
final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root);
final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root));
final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root);
final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root));
final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root);
final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root);
final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root);
final String city = this.fromJsonHelper.extractStringNamed("city", root);
final String district = this.fromJsonHelper.extractStringNamed("district", root);
final String state = this.fromJsonHelper.extractStringNamed("state", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE;
final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root);
final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE;
final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root);
final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root);
final String website = this.fromJsonHelper.extractStringNamed("website", root);
final String pan = this.fromJsonHelper.extractStringNamed("pan", root);
final String tan = this.fromJsonHelper.extractStringNamed("tan", root);
final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root);
final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root);
final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root);
final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root);
final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root);
final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root);
final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation",
root);
final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root);
final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root);
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country,
regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic,
addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress,
communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail,
website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions,
authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail,
turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(),
Timestamp.from(Instant.now()));
return get();
}
private void ensureRowExists() {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1",
Integer.class);
if (count == null || count == 0) {
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
INSERT INTO m_institution_profile (id, status, created_by_username, created_at)
VALUES (1, 'ACTIVE', ?, ?)
""", currentUser.getUsername(), Timestamp.from(Instant.now()));
}
}
/** The tenant's one enabled currency (see the INR-only enforcement migration) — read-only here. */
private String resolveCurrencyCode() {
final List<String> codes = jdbcTemplate.queryForList(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
return codes.isEmpty() ? null : codes.get(0);
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("bankName", row.get("bank_name"));
out.put("legalName", row.get("legal_name"));
out.put("shortName", row.get("short_name"));
out.put("bankCode", row.get("bank_code"));
out.put("institutionCode", row.get("institution_code"));
out.put("bankType", row.get("bank_type"));
out.put("ownershipType", row.get("ownership_type"));
out.put("parentInstitutionName", row.get("parent_institution_name"));
out.put("registrationNumber", row.get("registration_number"));
out.put("licenseNumber", row.get("license_number"));
out.put("establishedDate", str(row.get("established_date")));
out.put("status", row.get("status"));
out.put("country", row.get("country"));
out.put("currencyCode", resolveCurrencyCode());
out.put("regulatoryAuthority", row.get("regulatory_authority"));
out.put("registrationDate", str(row.get("registration_date")));
out.put("licenseStatus", row.get("license_status"));
out.put("licenseExpiryDate", str(row.get("license_expiry_date")));
out.put("swiftBic", row.get("swift_bic"));
out.put("addressLine1", row.get("address_line1"));
out.put("addressLine2", row.get("address_line2"));
out.put("city", row.get("city"));
out.put("district", row.get("district"));
out.put("state", row.get("state"));
out.put("pincode", row.get("pincode"));
out.put("headOfficeSameAsRegistered", row.get("head_office_same_as_registered"));
out.put("headOfficeAddress", row.get("head_office_address"));
out.put("communicationSameAsRegistered", row.get("communication_same_as_registered"));
out.put("communicationAddress", row.get("communication_address"));
out.put("contactPhone", row.get("contact_phone"));
out.put("contactEmail", row.get("contact_email"));
out.put("customerCareNumber", row.get("customer_care_number"));
out.put("grievanceEmail", row.get("grievance_email"));
out.put("website", row.get("website"));
out.put("pan", row.get("pan"));
out.put("tan", row.get("tan"));
out.put("gstin", row.get("gstin"));
out.put("statementHeaderName", row.get("statement_header_name"));
out.put("passbookHeaderName", row.get("passbook_header_name"));
out.put("receiptHeaderName", row.get("receipt_header_name"));
out.put("termsAndConditions", row.get("terms_and_conditions"));
out.put("authorizedPersonName", row.get("authorized_person_name"));
out.put("authorizedPersonDesignation", row.get("authorized_person_designation"));
out.put("authorizedPersonContact", row.get("authorized_person_contact"));
out.put("authorizedPersonEmail", row.get("authorized_person_email"));
out.put("createdByUsername", row.get("created_by_username"));
out.put("createdAt", str(row.get("created_at")));
out.put("activationDate", str(row.get("activation_date")));
out.put("deactivationDate", str(row.get("deactivation_date")));
out.put("updatedByUsername", row.get("updated_by_username"));
out.put("updatedAt", str(row.get("updated_at")));
return out;
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
}
@@ -33,4 +33,9 @@
<include file="parts/0507_simplify_teller_payment_types.xml" relativeToChangelogFile="true"/>
<include file="parts/0508_retire_share_redemption_transfer_payment_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0509_retire_all_system_defined_payment_types.xml" relativeToChangelogFile="true"/>
<include file="parts/0510_branch_day_register_denomination_detail.xml" relativeToChangelogFile="true"/>
<include file="parts/0511_institution_and_branch_profile.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/0514_grant_teller_roles_read_clientnominee.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?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">
<!-- The branch day register (0489) only ever stored opening_cash and closing_cash as a single
lump-sum figure. That answers "how much cash does the branch have" but not "how many of
each note/coin" - the question that actually matters for a vault deciding whether it can
physically hand a cashier fifty 500-rupee notes. This table is the denomination breakdown
behind that lump sum, one row per denomination per leg (OPENING or CLOSING) of a business
day. Without it there is no source of truth for the branch's own starting stock, only for
the vault-to-cashier/cashier-to-vault flows layered on top of it. -->
<changeSet id="0510_create_branch_day_register_detail" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_branch_day_register_detail"/></not>
</preConditions>
<createTable tableName="m_branch_day_register_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="register_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="leg" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_branch_day_register_detail" indexName="idx_branch_day_detail_register">
<column name="register_id"/>
<column name="leg"/>
</createIndex>
<addForeignKeyConstraint baseTableName="m_branch_day_register_detail" baseColumnNames="register_id"
constraintName="fk_branch_day_detail_register" referencedTableName="m_branch_day_register"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,135 @@
<?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">
<!-- m_office is a stock Fineract entity referenced by 25+ foreign keys and managed by its own
JPA/EclipseLink mapping - adding real-world branch fields (code, address, contact, type,
working hours) directly onto it would mean touching Office.java and every read/write path
that already depends on its exact shape. Instead this is a side table keyed one-to-one on
office_id, the same pattern already used for m_branch_day_register and m_tellers - the
branch's operational profile lives beside the core office record, not inside it. -->
<changeSet id="0511_create_office_profile" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_office_profile"/></not>
</preConditions>
<createTable tableName="m_office_profile">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false" unique="true"/>
</column>
<column name="branch_code" type="VARCHAR(20)">
<constraints unique="true"/>
</column>
<column name="branch_type" type="VARCHAR(30)"/>
<column name="address" type="VARCHAR(500)"/>
<column name="contact_phone" type="VARCHAR(20)"/>
<column name="contact_email" type="VARCHAR(100)"/>
<column name="working_hours_start" type="VARCHAR(10)"/>
<column name="working_hours_end" type="VARCHAR(10)"/>
<column name="working_days" type="VARCHAR(100)"/>
<column name="updated_by_username" type="VARCHAR(100)"/>
<column name="updated_at" type="TIMESTAMP"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_office_profile" baseColumnNames="office_id"
constraintName="fk_office_profile_office" referencedTableName="m_office"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
<!-- One row, always id=1 - the institution's own profile is a singleton, not a list. The API
creates this row lazily on first read if it is ever missing rather than relying on a seed
row surviving every possible migration path. -->
<changeSet id="0511_create_institution_profile" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_institution_profile"/></not>
</preConditions>
<createTable tableName="m_institution_profile">
<column name="id" type="BIGINT">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="bank_name" type="VARCHAR(150)"/>
<column name="legal_name" type="VARCHAR(200)"/>
<column name="bank_code" type="VARCHAR(30)"/>
<column name="registration_number" type="VARCHAR(50)"/>
<column name="address" type="VARCHAR(500)"/>
<column name="contact_phone" type="VARCHAR(20)"/>
<column name="contact_email" type="VARCHAR(100)"/>
<column name="established_date" type="DATE"/>
<column name="updated_by_username" type="VARCHAR(100)"/>
<column name="updated_at" type="TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="0511_add_org_setup_permissions" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_INSTITUTIONPROFILE'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_INSTITUTIONPROFILE"/>
<column name="entity_name" value="INSTITUTIONPROFILE"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_INSTITUTIONPROFILE"/>
<column name="entity_name" value="INSTITUTIONPROFILE"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_BRANCHPROFILE"/>
<column name="entity_name" value="BRANCHPROFILE"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_BRANCHPROFILE"/>
<column name="entity_name" value="BRANCHPROFILE"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_CLEARINGGLCONFIG"/>
<column name="entity_name" value="CLEARINGGLCONFIG"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_CLEARINGGLCONFIG"/>
<column name="entity_name" value="CLEARINGGLCONFIG"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Granted to Super user and any manager-named role, matching the grant pattern already used
for the branch-ops menu permission (0489) - organisation setup is a manager/admin act, not
a teller or cashier one. -->
<changeSet id="0511_grant_org_setup_permissions" author="cbs-nitesh">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE p.code IN ('READ_INSTITUTIONPROFILE', 'UPDATE_INSTITUTIONPROFILE',
'READ_BRANCHPROFILE', 'UPDATE_BRANCHPROFILE',
'READ_CLEARINGGLCONFIG', 'UPDATE_CLEARINGGLCONFIG')
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,59 @@
<?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">
<!-- 0511's institution profile only covered name/code/registration/address-as-one-field/contact.
A real UCB's bank master carries a lot more: a short name, a distinct institution code
(separate from the bank code), a bank type, a license number, an active/inactive status, a
structured address (line 1/2, city, district, state, pincode - not one free-text block),
website and customer-care number, and the entity-level regulatory identifiers (PAN, TAN,
GSTIN) and the person authorised to represent the institution. Adding these as new nullable
columns rather than reshaping 0511 - that migration already shipped and its table may
already hold a row. -->
<changeSet id="0512_expand_institution_profile" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_institution_profile" columnName="short_name"/></not>
</preConditions>
<addColumn tableName="m_institution_profile">
<column name="short_name" type="VARCHAR(50)"/>
<column name="institution_code" type="VARCHAR(30)"/>
<column name="bank_type" type="VARCHAR(30)"/>
<column name="license_number" type="VARCHAR(50)"/>
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"/>
<column name="country" type="VARCHAR(50)" defaultValue="India"/>
<column name="address_line1" type="VARCHAR(200)"/>
<column name="address_line2" type="VARCHAR(200)"/>
<column name="city" type="VARCHAR(100)"/>
<column name="district" type="VARCHAR(100)"/>
<column name="state" type="VARCHAR(100)"/>
<column name="pincode" type="VARCHAR(10)"/>
<column name="customer_care_number" type="VARCHAR(20)"/>
<column name="website" type="VARCHAR(150)"/>
<column name="pan" type="VARCHAR(15)"/>
<column name="tan" type="VARCHAR(15)"/>
<column name="gstin" type="VARCHAR(20)"/>
<column name="authorized_person_name" type="VARCHAR(150)"/>
<column name="authorized_person_designation" type="VARCHAR(100)"/>
<column name="authorized_person_contact" type="VARCHAR(20)"/>
<column name="authorized_person_email" type="VARCHAR(100)"/>
</addColumn>
</changeSet>
<!-- IFSC, MICR and SWIFT/BIC identify a specific branch, not the institution as a whole - a bank
with five branches has five different IFSC codes. These belong on the branch profile, not
the institution profile, exactly as flagged. -->
<changeSet id="0512_add_branch_identifiers" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_office_profile" columnName="ifsc_code"/></not>
</preConditions>
<addColumn tableName="m_office_profile">
<column name="ifsc_code" type="VARCHAR(15)"/>
<column name="micr_code" type="VARCHAR(15)"/>
<column name="swift_bic" type="VARCHAR(15)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,78 @@
<?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">
<!-- Rounds out the Bank Master to the fields a real UCB core banking system carries at the
institution level: ownership/parent classification, the regulatory details beyond a bare
registration number (authority, registration date, licence status/expiry), a corrected
SWIFT/BIC placement (institution-level - a bank has one, unlike IFSC/MICR which are
per-branch and stay on m_office_profile), head-office/communication address as optional
overrides of the registered address, a grievance contact, document header names, terms and
conditions text, and proper created/activation audit fields alongside the update ones 0511
already had.
Deliberately NOT added as columns here, even though they were suggested: Business Date,
Financial Year, Time Zone, Working Days, Holiday Calendar, date/number format - these
already exist as their own configuration (Branch Day Register, GL Closures, Working Days,
Bank Calendar) and duplicating them into this table would just be a second, driftable copy
of the same fact. The Bank Profile screen links out to those screens instead of storing a
shadow value. Likewise skipped: a vague "bank category/sub-category" and "other applicable
identifiers" with no defined values, and a logo/letterhead image (already handled by the
tenant's own env-level logo config, and a file-upload field is out of scope for this
screen). -->
<changeSet id="0513_expand_institution_profile_full" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_institution_profile" columnName="ownership_type"/></not>
</preConditions>
<addColumn tableName="m_institution_profile">
<column name="ownership_type" type="VARCHAR(30)"/>
<column name="parent_institution_name" type="VARCHAR(150)"/>
<column name="regulatory_authority" type="VARCHAR(150)"/>
<column name="registration_date" type="DATE"/>
<column name="license_status" type="VARCHAR(20)"/>
<column name="license_expiry_date" type="DATE"/>
<column name="swift_bic" type="VARCHAR(15)"/>
<column name="head_office_same_as_registered" type="BOOLEAN" defaultValueBoolean="true"/>
<column name="head_office_address" type="VARCHAR(500)"/>
<column name="communication_same_as_registered" type="BOOLEAN" defaultValueBoolean="true"/>
<column name="communication_address" type="VARCHAR(500)"/>
<column name="grievance_email" type="VARCHAR(100)"/>
<column name="statement_header_name" type="VARCHAR(150)"/>
<column name="passbook_header_name" type="VARCHAR(150)"/>
<column name="receipt_header_name" type="VARCHAR(150)"/>
<column name="terms_and_conditions" type="TEXT"/>
<column name="created_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP"/>
<column name="activation_date" type="DATE"/>
<column name="deactivation_date" type="DATE"/>
</addColumn>
</changeSet>
<!-- Branch profile keeps IFSC/MICR (genuinely per-branch) and gains latitude/longitude for the
branch's physical location - a real use case at branch level (maps, nearest-branch lookup),
not at the institution level. SWIFT/BIC moves the other way: out of the branch table, since
it was placed there under an earlier, less precise read of which identifier belongs where -
a bank has one SWIFT/BIC, not one per branch, matching migration 0513's institution-level
column above. Zero real branch profile rows exist yet (this whole feature shipped this same
session), so there is no data to migrate off the column before dropping it. -->
<changeSet id="0513_add_branch_geo_and_drop_branch_swift" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_office_profile" columnName="latitude"/></not>
</preConditions>
<addColumn tableName="m_office_profile">
<column name="latitude" type="DECIMAL(9,6)"/>
<column name="longitude" type="DECIMAL(9,6)"/>
</addColumn>
<dropColumn tableName="m_office_profile" columnName="swift_bic"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?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">
<!-- READ_CLIENTNOMINEE was never granted to any role at all (only Super user's ALL_FUNCTIONS
bypass ever passed this check) - a gap that only surfaced now because the cashier/clerk
Transaction Entry screen genuinely displays a client's nominees while an account is
loaded. That data is real and shown on screen (not a call that can simply be skipped),
so the fix is the permission grant, not removing the read. -->
<changeSet id="0514_grant_teller_roles_read_clientnominee" author="cbs-nitesh">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE p.code = 'READ_CLIENTNOMINEE'
AND r.name IN ('CASHIER', 'Clerk', 'Teller', 'Branch Manager', 'Principal Officer', 'As Manager')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>