fix(shares,voucher,cashreport): route redeem/withdrawal through Sundry, fix voucher cashier attribution and GL-name resolution
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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 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-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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract 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 9 of 10) (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 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

- ClerkShareWithdrawalRequestApiResource: reverse native redeem GL legs and reroute through Sundry
  (Dr Share Reference / Cr Sundry, then Dr Sundry / Cr Savings Control), reusing the native
  transaction_id so both legs display as balanced; wholesale-reverse and reroute the linked savings
  credit the same way.
- ShareAccountWritePlatformServiceJpaRepositoryImpl: undoApproveShareAccount no longer re-triggers
  the native "isApplied()" phantom GL posting Fineract's accounting bridge makes on approval.
- VoucherService/ClerkWithdrawalRequestApiResource: attribute a voucher to the cashier who actually
  disbursed cash, not the submitting clerk, once disbursal completes.
- CashReportApiResource: scope the GL-entries deposit/withdrawal joins by txn_type to stop a
  hex/decimal txn_id collision from borrowing an unrelated transaction's voucher number; resolve
  GL account names (not just client names) in the "A/C NAME" column.

fix(branchday,cashreport): stop the vault position from double-counting a cashier's unsettled cash

BranchDayRegisterApiResource's day-close folded every cashier's still-held cash into the branch's
combined closing figure, which then carried forward as next day's vault opening. When that cash was
later settled back to the vault, CashReportApiResource added it a second time - once already baked
into the carried-forward opening, once as the settlement itself - manufacturing a phantom "Excess"
equal to whatever was unsettled.

Adds a vault-only OPENING_VAULT/CLOSING_VAULT leg (new VaultPositionService, shared by both
resources) alongside the existing branch-total OPENING/CLOSING legs, so the vault's own anchor never
includes cash that still physically sits with a cashier.

feat(accounting): seed a missing opening-balance contra account and a real bank's chart of accounts

- Financial Activity 300 (opening-balance transfer contra) was never mapped in any tenant's chart of
  accounts, so any opening-balance flow failed outright; backfilled it and added a migration to seed
  it for every tenant going forward.
- Seeds a brand-new, otherwise-empty tenant with a real cooperative bank's chart of accounts
  (transcribed from its balance sheet), guarded to never touch a tenant that already has its own.
- Removes 0451's hardcoded demo branch offices, which were seeding into every new tenant regardless
  of institution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:06:57 +05:30
co-authored by Claude Sonnet 5
parent 1130a482a0
commit 509370eaeb
13 changed files with 797 additions and 186 deletions
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityEx
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -64,6 +65,7 @@ public class BranchDayRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -271,6 +273,7 @@ public class BranchDayRegisterApiResource {
Timestamp.from(Instant.now()), carried, opensWithADifference ? openingDifferenceReason.trim() : null);
saveDenominationDetail(id, "OPENING", effectiveDenominations);
carryForwardVaultDenominations(officeId, id, businessDate);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("resourceId", id);
@@ -342,6 +345,7 @@ public class BranchDayRegisterApiResource {
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
snapshotVaultDenominations(officeId, registerId, businessDate);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
@@ -448,6 +452,16 @@ public class BranchDayRegisterApiResource {
}
private void saveDenominationDetail(final Long registerId, final String leg, final List<Map<String, Object>> denominations) {
saveDenominationDetail(registerId, leg, null, denominations);
}
/**
* {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier
* added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see
* {@link VaultPositionService} for why the two are never allowed to mix.
*/
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
final List<Map<String, Object>> denominations) {
if (denominations == null || denominations.isEmpty()) {
return;
}
@@ -456,10 +470,67 @@ public class BranchDayRegisterApiResource {
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 (?,?,?,?,?,?)
(register_id, leg, denomination_id, denomination_value, count, total_amount, teller_id)
VALUES (?,?,?,?,?,?,?)
""", registerId, leg, row.get("denominationId"), value, count,
BigDecimal.valueOf((long) value * count));
BigDecimal.valueOf((long) value * count), tellerId);
}
}
/** Copies one register's vault-only CLOSING_VAULT rows into another register's OPENING_VAULT,
* per teller — the vault-scoped analogue of {@link #lastClosingDenominations}. */
private void carryForwardVaultDenominations(final Long officeId, final Long newRegisterId, final LocalDate businessDate) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ?", Long.class,
officeId);
for (final Long tellerId : tellerIds) {
final List<Long> priorRegisterIds = jdbcTemplate.queryForList("""
SELECT r.id FROM m_branch_day_register r
WHERE r.office_id = ? AND r.business_date < ?
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
WHERE d.register_id = r.id AND d.leg = 'CLOSING_VAULT' AND d.teller_id = ?)
ORDER BY r.business_date DESC LIMIT 1
""", Long.class, officeId, Date.valueOf(businessDate), tellerId);
if (priorRegisterIds.isEmpty()) {
continue;
}
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_VAULT' AND teller_id = ?
""", priorRegisterIds.get(0), tellerId);
saveDenominationDetail(newRegisterId, "OPENING_VAULT", tellerId, rows);
}
}
/** Snapshots what each teller under this office is holding right now, as CLOSING_VAULT — the
* input {@link #carryForwardVaultDenominations} carries into tomorrow's OPENING_VAULT. Read
* from {@link VaultPositionService} rather than from whatever was physically counted at close,
* so the vault-only chain stays self-consistent even when a closer's manual count differs from
* what the books show — that difference is a book-vs-physical question, not a vault-vs-cashier
* split question. */
private void snapshotVaultDenominations(final Long officeId, final Long registerId, final LocalDate businessDate) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ?", Long.class,
officeId);
for (final Long tellerId : tellerIds) {
final Map<String, Object> position = vaultPositionService.buildPositionForVault(tellerId, businessDate.toString());
if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
continue;
}
@SuppressWarnings("unchecked")
final List<Map<String, Object>> denoms = (List<Map<String, Object>>) position.get("denominations");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> d : denoms) {
final int count = ((Number) d.get("count")).intValue();
if (count <= 0) {
continue;
}
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", d.get("denominationId"));
row.put("denominationValue", ((Number) d.get("denominationValue")).intValue());
row.put("count", count);
rows.add(row);
}
saveDenominationDetail(registerId, "CLOSING_VAULT", tellerId, rows);
}
}
@@ -15,6 +15,7 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -30,6 +31,7 @@ public class CashReportApiResource {
private final PlatformSecurityContext context;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
@@ -542,11 +544,7 @@ public class CashReportApiResource {
* 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.
* See {@link VaultPositionService} for how the anchor is chosen.
*/
@GET
@Path("vault-position")
@@ -559,107 +557,11 @@ public class CashReportApiResource {
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));
all.add(vaultPositionService.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;
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
@@ -914,12 +816,23 @@ public class CashReportApiResource {
// contains a-f, is never sent through the hex cast at all).
final String baseGroupSql = "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
+ "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
// A GL-type row (no client_id) carries the GL account's own code in account_no
// rather than a savings/current account number, so its "name" has to come from the
// chart of accounts, not from a client that was never party to the transaction.
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) "
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ officerFilter
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
@@ -942,7 +855,7 @@ public class CashReportApiResource {
final String dataSql = "SELECT "
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
+ " std.account_no AS ac_no, "
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
+ " MAX(std.txn_date) AS txn_date, "
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
+ " MAX(std.officer_username) AS teller_name, "
@@ -0,0 +1,169 @@
/**
* 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.cashreport.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
/**
* Denomination-wise cash a vault (teller) is currently holding.
*
* Anchored on the most recent business day (on or before the target date) whose branch day
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
* teller), then walked forward by everything settled into this vault and handed out of it since.
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
* never includes cash that still physically sits with a cashier — mixing the two double-counts
* whatever a cashier settles back later, once already folded into the branch total, again as the
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
* still returns something rather than zero.
*/
@Component
@RequiredArgsConstructor
public class VaultPositionService {
private final JdbcTemplate jdbcTemplate;
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
public 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 (final 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;
}
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
// forward day to day, with no cashier's held cash folded in. Fall back to the old
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
// before the target date (a day closed before vault-only tracking existed).
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_VAULT' AND d.teller_id = ?)
ORDER BY r.business_date DESC LIMIT 1
""", officeId, dateFilter, tellerId);
String anchorLeg = "OPENING_VAULT";
if (anchorRows.isEmpty()) {
anchorLeg = "OPENING";
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 = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
""", DENOM_ROW, registerId, anchorLeg, tellerId), 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;
}
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
for (final Object[] row : rows) {
final Long denomId = (Long) row[0];
final int denomValue = (Integer) row[1];
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
final long count = (Long) row[3];
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
}
@@ -41,6 +41,8 @@ import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@@ -55,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -97,6 +100,20 @@ public class ClerkShareWithdrawalRequestApiResource {
// instead of silently posting its settlement with none at all.
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
// ── Sundry pass-through for the redemption settlement ───────────────────
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
// acc_product_mapping.financial_account_type for share products.
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int PRODUCT_TYPE_SHARES = 4;
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
@POST
@@ -629,11 +646,20 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
// "give cash back over the counter" assumption breaks that the moment the settlement below
// lands on a different GL than this leg did.
if (shareTransactionId != null) {
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
currentUser.getId());
}
// Settlement: within this SAME approval transaction, immediately credit the redemption value
// to the member's linked savings account. Two Fineract journal entries get posted — native
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
? ((Number) storedSettlementPaymentTypeId).longValue()
@@ -715,14 +741,17 @@ public class ClerkShareWithdrawalRequestApiResource {
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
* the unit price ourselves — the transaction was already priced by Fineract's own
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
* command so the transaction is fully audited and posts its own GL entry the normal way — the
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
* on that bridge account. Falls back to the legacy hardcoded
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
* column existed.
* today) and already nets out any redemption charge.
*
* <p>
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
* to be left to the payment type's own mapping in the hope it would land on the same account
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
*/
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final String resolutionNumber, final Long settlementPaymentTypeId) {
@@ -761,7 +790,35 @@ public class ClerkShareWithdrawalRequestApiResource {
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString()).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return new Settlement(settlementAmount, result.getResourceId());
final Long savingsTransactionId = result.getResourceId();
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
final long userId = context.authenticatedUser().getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHRDMSTL-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settled to savings | " + accountNo);
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settlement credited to savings | " + accountNo);
return new Settlement(settlementAmount, savingsTransactionId);
}
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
@@ -832,6 +889,125 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
// ── Sundry pass-through helpers ──────────────────────────────────────────
/**
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
*
* <p>
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
* the same way every other share-capital movement in this codebase does.
*/
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
final long userId) {
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Map<String, Object> reversedLeg = jdbcTemplate
.queryForList(
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT)
.stream().findFirst().orElse(null);
if (reversedLeg == null) {
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
// unexpected accounting configuration), so there is no leg here to reroute.
return;
}
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
// instead of the two reading as the one balanced entry they actually are.
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
+ " AND type_enum = ? AND reversed = false",
shareTransactionId, glReference, CREDIT);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption held in Sundry pending settlement | " + accountNo);
}
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
return jdbcTemplate.queryForMap("""
SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id
WHERE sa.id = ?
""", shareAccountId);
}
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw apiError(409, label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (final IncorrectResultSizeDataAccessException e) {
return null;
}
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back
* as 00:04 the next day. The instant has to be resolved in the tenant's own zone first, which is the zone
* {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
@@ -605,6 +605,11 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
}
// The voucher was raised in the clerk's name at submission, before any cashier was involved --
// reattribute it to whoever is actually handing over the cash now, so it shows up in their own
// voucher register rather than only in the clerk's.
voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser);
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
if (denominationData != null && !denominationData.isBlank()) {
saveDisbursementDenomination(denominationData, record, currentUser);
@@ -340,9 +340,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
}
}
LocalDate transactionDate = DateUtils.getBusinessLocalDate();
// Reverts whatever approveShareAccount() posted -- which, per the comment there, is
// nothing. Kept anyway as a no-op safety net for any transaction that predates that fix.
this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate);
journalEntryWritePlatformService.createJournalEntriesForShares(
populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
// Deliberately NOT re-running the accounting bridge for the transactions this undo just
// put back to APPLIED. That bridge's "isApplied" branch posts Dr Shares Reference (Cash at
// Vault) / Cr Share Sundry, assuming cash was already received at the counter for these
// shares -- exactly the phantom entry approveShareAccount()/createShareAccount() were
// fixed to stop posting. Calling it here reintroduced the same phantom entry by a side
// door: undo-approve reverting a transaction to APPLIED status looks, to the bridge, just
// like a brand-new application being submitted. Money only ever reaches or leaves share
// capital through ShareAdvanceCollectionApiResource's own explicit Sundry-routed postings.
return new CommandProcessingResultBuilder() //
.withCommandId(jsonCommand.commandId()) //
.withEntityId(accountId) //
@@ -467,4 +467,19 @@ public class VoucherService {
"UPDATE m_voucher SET amount = ? WHERE reference_type = ? AND reference_id = ?",
amount, referenceType, referenceId);
}
/**
* Reattributes a voucher to the cashier who actually disbursed the cash, once that is known.
*
* A clerk withdrawal is raised by the clerk who takes the request, so that is who
* {@link #createVoucher} stamps as the creator at submission time -- but no cash has moved yet,
* and it is a cashier, at their own counter, who later hands it over. A voucher names whoever's
* till the money actually came out of, so the per-user "your own vouchers" register a cashier
* sees is complete rather than silently missing every withdrawal they personally paid out.
*/
public void updateCashier(final String referenceType, final Long referenceId, final AppUser cashier) {
jdbcTemplate.update(
"UPDATE m_voucher SET created_by = ?, created_by_username = ? WHERE reference_type = ? AND reference_id = ?",
cashier.getId(), cashier.getUsername(), referenceType, referenceId);
}
}
@@ -502,7 +502,6 @@
<include file="parts/0447_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0448_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
<include file="parts/0450_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
<include file="parts/0451_seed_branch_offices.xml" relativeToChangelogFile="true"/>
<include file="parts/0452_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
@@ -47,4 +47,7 @@
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
<include file="parts/0519_post_savings_interest_at_period_end.xml" relativeToChangelogFile="true"/>
<include file="parts/0520_drop_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0521_branch_day_register_vault_only_detail.xml" relativeToChangelogFile="true"/>
<include file="parts/0522_seed_opening_balance_contra_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_seed_basoda_bank_chart_of_accounts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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.13.xsd">
<!--
Branch offices under Head Office.
Until now the tenant had a single office, so "see only my branch" had nothing to
scope to. external_id carries the branch code used by the General Ledger and the
RBI return screens, which is what lets a logged-in user's office be resolved to a
branch without matching on display names.
-->
<changeSet author="fineract" id="0451-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006')</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_office (parent_id, hierarchy, external_id, name, opening_date)
VALUES
((SELECT id FROM m_office WHERE hierarchy = '.'), '.2.', '0002', 'Andheri (E)', DATE '2018-04-01'),
((SELECT id FROM m_office WHERE hierarchy = '.'), '.3.', '0003', 'Bandra (W)', DATE '2018-04-01'),
((SELECT id FROM m_office WHERE hierarchy = '.'), '.4.', '0004', 'Borivali', DATE '2019-07-01'),
((SELECT id FROM m_office WHERE hierarchy = '.'), '.5.', '0005', 'Dadar', DATE '2020-01-02'),
((SELECT id FROM m_office WHERE hierarchy = '.'), '.6.', '0006', 'Thane', DATE '2021-06-01');
</sql>
</changeSet>
<!-- Head Office carries the branch code the GL ledger uses for it. -->
<changeSet author="fineract" id="0451-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_office WHERE external_id = 'HO'</sqlCheck>
</preConditions>
<sql>
UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- OPENING/CLOSING rows in m_branch_day_register_detail (0510) are the branch total: vault plus
every cashier's cash added together. That total is correct for the day's book reconciliation
(closing_cash vs book_cash is a branch-wide question), but the vault-position report reused
the same rows as if they were the vault's own cash alone. Whatever a cashier was still
holding, unsettled, at close got carried forward as if it belonged to the vault, then counted
again the day that cash was actually settled back to the vault, inflating the vault's reported
position by exactly the unsettled amount. This adds a second, vault-only pair of legs so the
vault-position report can anchor on the vault's real cash instead of the branch total. -->
<changeSet id="0521_widen_branch_day_detail_leg" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_branch_day_register_detail" columnName="leg"/>
</preConditions>
<modifyDataType tableName="m_branch_day_register_detail" columnName="leg" newDataType="VARCHAR(20)"/>
</changeSet>
<changeSet id="0521_add_branch_day_detail_teller" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_branch_day_register_detail" columnName="teller_id"/></not>
</preConditions>
<addColumn tableName="m_branch_day_register_detail">
<!-- NULL on every existing (and every future branch-total) row - only the new
OPENING_VAULT/CLOSING_VAULT rows carry a teller_id, which is what tells
buildPositionForVault which rows are its own. -->
<column name="teller_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_branch_day_register_detail" baseColumnNames="teller_id"
constraintName="fk_branch_day_detail_teller" referencedTableName="m_tellers"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,36 @@
<?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">
<!-- Financial activity 300 (OPENING_BALANCES_TRANSFER_CONTRA) is the equity contra Fineract
posts against whenever an account is opened with a non-zero opening balance - exactly the
"conversion suspense" leg a live bank's opening balances get migrated through. No chart of
accounts in this fork ever seeded a GL account for it, so any screen that touches an opening
balance (including plain report/account-open flows) fails with "Financial Activity account
... with Id 300 does not exist" the first time it is hit. This backfills the missing account
and its mapping for every tenant - existing ones now, and any new tenant going forward. -->
<changeSet id="0522_seed_opening_balance_contra_account" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '300000'</sqlCheck>
</preConditions>
<sql>
INSERT INTO acc_gl_account
(name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum)
VALUES ('Opening Balances Transfer Contra', '300000', false, true, 1, 3);
</sql>
</changeSet>
<changeSet id="0522_map_opening_balance_contra_financial_activity" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 300</sqlCheck>
</preConditions>
<sql>
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id)
VALUES (300, (SELECT id FROM acc_gl_account WHERE gl_code = '300000'));
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,237 @@
<?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">
<!-- Seeds a brand-new tenant with Basoda Nagrik Sahakari Bank's real chart of accounts,
transcribed line by line from its 31/03/2026 balance sheet. Guarded on "no GL account exists
other than the opening-balance contra" rather than "GL is completely empty" - 0522
(immediately before this one) seeds every tenant, including a fresh one, with that one GL
account before this changeset runs, so a plain empty-table check would never be true and
this would silently never seed a single future tenant, regardless of which of the two runs
first. An existing tenant that already built its own chart of accounts (socity_bank,
default) has plenty of other accounts and is never touched - only a genuinely fresh one is
(this is how abc_bank got seeded, and how any future new tenant will be too). The
opening-balance contra account and its financial-activity-300 mapping are 0522's job, not
repeated here. -->
<changeSet id="0523_seed_basoda_bank_chart_of_accounts" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code != '300000'</sqlCheck>
</preConditions>
<sql>
<![CDATA[
-- classification_enum: 1=ASSET, 2=LIABILITY, 3=EQUITY, 4=INCOME, 5=EXPENSE
-- account_usage: 2=header (group, not postable), 1=detail (postable leaf)
-- ============================= EQUITY (3) =============================
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('CAPITAL', '301000', false, true, 2, 3);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('LINKING SHARE A/C', '301001', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='301000')),
('SHARE HOLDER''S A/C', '301002', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='301000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('RESERVES & OTHER RESERVES', '302000', false, true, 2, 3);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('BAD & DOUBTFUL RESERVE FUND', '302001', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('BUILDING FUND A/C', '302002', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('CHARITABLE FUND A/C', '302003', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('COMPUTER FUND A/C', '302004', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('DEVELOPMENT FUND A/C', '302005', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('DIVIDEND EQUALIZATION FUND', '302006', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('EMPLOYEE WELFARE FUND A/C', '302007', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('INVESTMENT DEPRECIATION RESERVE', '302008', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('INVESTMENT FLUCTUATION FUND', '302009', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('STATUTORY RESERVE FUND', '302010', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('TRAINING FUND A/C', '302011', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000')),
('VEHICLE FUND A/C', '302012', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='302000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('P & L A/C', '303000', false, true, 2, 3);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('PROFIT A/C', '303001', false, true, 1, 3, (SELECT id FROM acc_gl_account WHERE gl_code='303000'));
-- ============================= LIABILITY (2) =============================
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('DEPOSIT', '201000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('CURRENT DEPOSIT INDIVIDUAL', '201001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('FIXED DEPOSIT DOUBLE', '201002', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('FIXED DEPOSIT SINGLE', '201003', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('MANGAL PARINAY A/C', '201004', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('MASIK DHAN VARSHA', '201005', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('NAVEEN MINI DEPOSIT', '201006', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('RECURRING DEPOSIT INDIVIDUAL', '201007', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('SAVING DEPOSIT INDIVIDUAL', '201008', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000')),
('SUPER MINI DEPOSIT A/C', '201009', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='201000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('OTHER LIABILITIES', '202000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('DIVIDEND PAYABLE', '202001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('DONATION TO JILA SAHAKARI SANGH', '202002', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('SHARE CAPITAL PAYABLE', '202003', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('SUNDRY CREDITORS A/C', '202004', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('SUSPENSE A/C', '202005', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('INCOME TAX OF BANK EMPLOYEE', '202006', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('T.D.S. TAX DEDUCTED AT SOURCE', '202007', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('ARREAR OF BANK EMPLOYEES', '202008', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('CGST PAYABLE A/C', '202009', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('SGST PAYABLE A/C', '202010', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('CREDIT BALANCE OF CASH', '202011', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('CREDIT BALANCE OF FCC LOAN', '202012', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('CREDIT BALANCE OF ML LOAN', '202013', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('BILLS FOR COLLECTION', '203000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('DD/BILLS PAYABLE A/C', '203001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='203000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('INTEREST PAYABLE', '204000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INTEREST ACCRUED FD SINGLE', '204001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='204000')),
('INTEREST ACCRUED FD DOUBLE', '204002', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='204000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('PROVISION', '205000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('AUDIT FEE PROVISION', '205001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='205000')),
('PROVISION FOR INCOME TAX LIABILITY', '205002', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='205000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('CONTINGENT LIABILITIES', '206000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('CONTINGENT PROVISION', '206001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='206000')),
('PROVISION FOR NPA A/C', '206002', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='206000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('CONTRA ACCOUNTS', '207000', false, true, 2, 2);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INTEREST PROVISION A/C', '207001', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='207000'));
-- ============================= ASSET (1) =============================
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('CASH ON HAND', '101000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('BRANCH CASH BALANCE', '101001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='101000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('BALANCE WITH OTHER BANK - CURRENT A/C', '102000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('CURRENT A/C IDBI BANK 00007252', '102001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C CO-OP. BANK MAIN BR.41', '102002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C CO-OP. BANK CITI BR.', '102003', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C UNION BANK OF INDIA', '102004', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('TRUSTY BASODA NAGRIK SAHAKARI BANK', '102005', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C HDFC BANK 0380000015', '102006', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C APEX BANK 187000801142', '102007', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C A.U. SMALL FINANCE BANK', '102008', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C DCB BANK 12822400150865', '102009', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C AXIS BANK LTD 071256359', '102010', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('IDBI BANK LTD CTS CLG A/C 000019', '102011', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C RBL BANK LTD 9001785747', '102012', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C BANK OF BARODA 001523', '102013', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C ICICI BANK BR. BASODA', '102014', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C YES BANK 001562 (ATM)', '102015', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C YES BANK-0071 (OUTWARD RTGS)', '102016', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C YES BANK-1552 (IMPS)', '102017', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C YES BANK-000198 (UPI)', '102018', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000')),
('CURRENT A/C DNSB BANK BBPS 645', '102019', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='102000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('FIX DEPOSIT WITH BANK', '103000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('FIXED DEPOSIT CO-OPER. BANK MAIN', '103001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='103000')),
('INVESTMENT IDBI BANK', '103002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='103000')),
('INVESTMENT WITH A.U. SMALL FINANCE BANK', '103003', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='103000')),
('INVESTMENT WITH D.C.B. BANK', '103004', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='103000')),
('INVESTMENT WITH R.B.L. BANK LTD', '103005', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='103000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('ACCRUED INTEREST', '104000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INTEREST A/C', '104001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='104000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('INVESTMENT', '105000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INVESTMENT WITH SIDBI FOR MSE', '105001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='105000')),
('NON-SLR INVESTMENT', '105002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='105000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('LOAN & ADVANCES', '106000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('CASH CREDIT LOAN A/C', '106001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('CC AGAINST DEPOSIT', '106002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('KISHAN CREDIT CARD', '106003', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('LOAN AGAINST DEPOSITE', '106004', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('LOAN AGAINST SECURITY', '106005', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('LOAN AGAINST WARE HOUSE RECEIPT', '106006', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('MEDIUM TERM LOAN', '106007', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('LONG TERM LOAN A/C', '106008', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000')),
('PROPERTY MORTGAGE LIMIT', '106009', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='106000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('INVESTMENTS', '107000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INVESTMENT IN GOVT. SECURITY', '107001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='107000')),
('INVESTMENT IN STATE GOVT. SECURITY', '107002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='107000')),
('INVESTMENT IN AFS CATEGORIES', '107003', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='107000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('OTHER ASSETS', '108000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('ADVANCES A/C', '108001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('INTEREST RECEIVABLE', '108002', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('LIBRARY A/C', '108003', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('STATIONERY & PRINTING', '108004', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('TELEPHONE RENT DEPOSIT', '108005', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('PLANT AND MACHINERY', '108006', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('ELECTRIFICATION BANK BUILDING', '108007', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000')),
('BANK SECURITY SYSTEM', '108008', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='108000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('LAND & BUILDING', '109000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('LAND & BUILDING UC', '109001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='109000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('FURNITURE & FIXTURE', '110000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('FURNITURE & FIXTURES A/C', '110001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='110000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('COMPUTER SOFTWARE AND HARDWARE', '111000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('COMPUTER SOFTWARE & HARDWARE', '111001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='111000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('SHARE OF OTHER CO.OP INSTITUTIONS', '112000', false, true, 2, 1);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('SHARE A/C DCCB', '112001', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code='112000'));
-- ============================= Financial Activity mappings =============================
-- Maps Fineract's internal cash/dividend mechanisms onto the closest matching account actually
-- present on this bank's balance sheet. Financial activity 300 (opening-balance contra) is 0522's
-- job, not repeated here - it is seeded generically for every tenant regardless of chart of
-- accounts, and always exists by the time this changeset runs.
INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) VALUES
(101, (SELECT id FROM acc_gl_account WHERE gl_code='101001')),
(102, (SELECT id FROM acc_gl_account WHERE gl_code='101001')),
(201, (SELECT id FROM acc_gl_account WHERE gl_code='202001')),
(202, (SELECT id FROM acc_gl_account WHERE gl_code='202001')),
(301, (SELECT id FROM acc_gl_account WHERE gl_code='302006'));
]]>
</sql>
</changeSet>
</databaseChangeLog>