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
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:
+156
-5
@@ -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);
|
||||
|
||||
+124
@@ -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
|
||||
|
||||
+123
@@ -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();
|
||||
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+274
@@ -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>
|
||||
|
||||
+49
@@ -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>
|
||||
+135
@@ -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>
|
||||
+59
@@ -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>
|
||||
+78
@@ -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>
|
||||
+28
@@ -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>
|
||||
Reference in New Issue
Block a user