fix(cash): correct branch-day reconciliation, voucher classification and duplicate posting bugs
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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 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 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract 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 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 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract 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
BranchDayRegisterApiResource: - Make openDay/closeDay transactional and add an atomic closed_at guard so a double-close or a mid-write failure cannot leave the register half-updated. - Fix bookBreakdown()'s slip/closure withdrawal queries: m_clerk_withdrawal_request and m_clerk_closure_request store cashier_id as an appuser id in some registers and an m_cashiers id in others; try both via a dual EXISTS instead of assuming one, which was silently dropping real withdrawals from expected cash and producing a false "Short" on close. - Office-scope vault-in/settled-out and close-preview queries so a multi-office tenant's close no longer mixes another branch's cash into this one's. - closeDay requires closingCash and uses DateUtils.getBusinessLocalDate() for late-close detection instead of the server's own LocalDate.now(). - saveDenominationDetail() now deletes any prior snapshot for the same register/leg/teller before inserting, so a repeated save no longer sums duplicate denomination rows into the vault position. - resolveOffice() enforces office-hierarchy authorization via PlatformSecurityContext.validateAccessRights(). CashReconciliationApiResource / CashReportApiResource / DenominationBreakdownWriter: - Add a MAX_PLAUSIBLE_TXN_ID filter (consistent with the existing cash report guard) so a fabricated/oversized transaction id can no longer distort a cashier's expected or counted position. - Tolerate sub-rupee (paisa) rounding differences instead of requiring an exact amount match — Indian cash has no paisa coins, so exact-equality was rejecting correctly-counted cash. - CashReconciliationApiResource now trusts its own server-computed expected count rather than the client-submitted one when recording a reconciliation. VaultPositionService: - Only fall back to the branch-total OPENING leg when the office has exactly one teller; a multi-teller branch was getting one teller's vault entry applied to all of them. CashSettlementApiResource / CashInterCashierApiResource / CashExchangeApiResource: - Make settlement creation transactional; add a partial unique index (0540) blocking two active settlements for the same cashier/day, closing a double-submit race the application-level count check alone could not. - Validate a cash transfer against the sending cashier's actual denomination holdings and require both cashiers to be in the same office. - Default cash exchange date to the business date instead of null, and add the missing CREATE/REVERSE permission checks (0539 seeds them). CashierDepositRequestApiResource: - Namespace pending-deposit denomination rows with a +500,000,000 offset so they cannot collide with real transaction ids during approve/reject. ClerkDisbursementRequestApiResource (Disbursement/Collection/Fund Transfer/Bank Transfer register): - executeSavingsWithdrawal() was missing the withOwnVoucher() suppression its sibling methods already had, so every Collection-mode approval raised a second, orphaned voucher for the same money on top of the request's own — double-counting the figure in Received and Payments on My Vouchers. - Voucher numbering no longer hard-codes CR/DR by money direction. It now checks whether the payment type the clerk actually selected is cash (m_payment_type.is_cash_payment) and numbers into the Journal series when it is not, since a Transfer/Cheque/DD instrument never crosses the counter regardless of which GL it posts against. Bank Transfer vouchers (always account-to-account, never cash) are now unconditionally Journal too. - The voucher's TYPE column stopped being hard-coded to "DISBURSEMENT" for every request this register raises; it now stores the clerk's own request type (Disbursement/Collection/Fund Transfer/Bank Transfer) so My Vouchers shows what was actually submitted. VoucherVerificationApiResource (/my-work): - Classify a voucher's side (Received/Payment/Journal) by whether either of its lines actually touches the till (Cash in Hand or a cash-at-vault/teller GL), the same test the register listing already uses, instead of trusting the voucher number's own CR/DR prefix at face value. A Collection or Disbursement posted against a plain GL (no cash involved) was being counted as real cash received/paid; it now reads as Journal, matching what actually happened at the counter. Existing CR/DR requests with no lines yet are left alone, since there is nothing to test the till against.
This commit is contained in:
+161
-25
@@ -45,6 +45,7 @@ import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
|||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branch day register — the formal opening and closing of a branch's business day.
|
* Branch day register — the formal opening and closing of a branch's business day.
|
||||||
@@ -70,6 +71,13 @@ public class BranchDayRegisterApiResource {
|
|||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||||
|
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||||
|
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||||
|
// never corrected -- without this same filter here, the close-preview's counted-in/counted-out
|
||||||
|
// figures would include that phantom amount, on top of the real cash the branch actually counted.
|
||||||
|
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||||
|
|
||||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,14 +191,33 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// What the cashier's own denomination entries add up to for the day. Where this and the
|
// What the cashier's own denomination entries add up to for the day. Where this and the
|
||||||
// expected figure agree, the day tallies and the closing figure is not in question.
|
// expected figure agree, the day tallies and the closing figure is not in question.
|
||||||
|
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||||
|
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||||
|
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||||
|
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||||
|
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE txn_date = ? AND txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
||||||
|
AND (std.officer_id IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
WHERE au.id = std.officer_id)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
||||||
|
AND (std.officer_id IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
WHERE au.id = std.officer_id)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
||||||
result.put("denominationIn", countedIn);
|
result.put("denominationIn", countedIn);
|
||||||
result.put("denominationOut", countedOut);
|
result.put("denominationOut", countedOut);
|
||||||
|
|
||||||
@@ -199,9 +226,15 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// ── POST /v1/branchday/open ───────────────────────────────────────────────
|
// ── POST /v1/branchday/open ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Transactional because opening a day is three separate writes -- the register row, its
|
||||||
|
// branch-total OPENING breakdown, and each teller's carried-forward OPENING_VAULT -- and a
|
||||||
|
// failure part-way through used to commit whatever had already run. A register left open with no
|
||||||
|
// OPENING_VAULT is not a cosmetic gap: the next close's carry-forward reaches past it to an older
|
||||||
|
// register instead, and the vault's opening balance silently regresses to a stale figure.
|
||||||
@POST
|
@POST
|
||||||
@Path("open")
|
@Path("open")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
public String openDay(final String body) {
|
public String openDay(final String body) {
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
requireDayAuthority(currentUser);
|
requireDayAuthority(currentUser);
|
||||||
@@ -461,9 +494,15 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Transactional for the same reason as openDay: the register UPDATE, the branch-total CLOSING
|
||||||
|
// breakdown and each teller's CLOSING_VAULT snapshot are separate writes. This database still
|
||||||
|
// carries the evidence of them committing independently -- a register whose closed_at stayed
|
||||||
|
// NULL while an orphaned CLOSING_VAULT snapshot survived, which later got summed alongside a
|
||||||
|
// second snapshot and doubled a vault's carried-forward opening balance.
|
||||||
@POST
|
@POST
|
||||||
@Path("close")
|
@Path("close")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
public String closeDay(final String body) {
|
public String closeDay(final String body) {
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
requireDayAuthority(currentUser);
|
requireDayAuthority(currentUser);
|
||||||
@@ -481,6 +520,16 @@ public class BranchDayRegisterApiResource {
|
|||||||
// counted note-by-note wins over a typed-in total.
|
// counted note-by-note wins over a typed-in total.
|
||||||
final BigDecimal closingCash = denominationTotal != null ? denominationTotal
|
final BigDecimal closingCash = denominationTotal != null ? denominationTotal
|
||||||
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
|
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
|
||||||
|
// A close needs a figure. Without one the row commits with closing_cash and cash_difference
|
||||||
|
// NULL, and every carry-forward lookup keys on "closing_cash IS NOT NULL" -- so the next open
|
||||||
|
// skips this register entirely and resurrects the day-before's note-for-note breakdown as
|
||||||
|
// tomorrow's opening cash, with no difference to explain and nothing on screen to suggest the
|
||||||
|
// chain broke. The UI always sends one; this endpoint is directly reachable and did not check.
|
||||||
|
if (closingCash == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.closing.cash.required",
|
||||||
|
"Record what was counted before closing business day " + businessDate
|
||||||
|
+ " -- either a closing figure or a denomination breakdown.");
|
||||||
|
}
|
||||||
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
||||||
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
|
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
|
||||||
|
|
||||||
@@ -577,7 +626,14 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
||||||
// but it must say why it was left open, and it is stamped as a force close.
|
// but it must say why it was left open, and it is stamped as a force close.
|
||||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
//
|
||||||
|
// Judged against the tenant's BUSINESS date, not the wall clock: a branch that finishes
|
||||||
|
// counting at 00:20 is still closing its own current day, and stamping that as a forced late
|
||||||
|
// close (with a mandatory "why was this left open" reason) both annoys the closer and
|
||||||
|
// destroys the one distinction this register exists for -- closed that evening versus closed
|
||||||
|
// nine days later. The same method already uses getBusinessLocalDate() for its own
|
||||||
|
// arithmetic; using two different notions of "today" in one close was the inconsistency.
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final boolean isLate = businessDate.isBefore(today);
|
final boolean isLate = businessDate.isBefore(today);
|
||||||
if (isLate && (forceReason == null || forceReason.isBlank())) {
|
if (isLate && (forceReason == null || forceReason.isBlank())) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
|
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
|
||||||
@@ -587,11 +643,18 @@ public class BranchDayRegisterApiResource {
|
|||||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||||
|
|
||||||
jdbcTemplate.update("""
|
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||||
|
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||||
|
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||||
|
// closing figure, closer and difference with no trace, and the two denomination writes
|
||||||
|
// interleaved into exactly the duplicate CLOSING rows saveDenominationDetail's
|
||||||
|
// delete-then-insert exists to prevent. Letting the database arbitrate makes the loser of
|
||||||
|
// that race fail cleanly instead.
|
||||||
|
final int closed = jdbcTemplate.update("""
|
||||||
UPDATE m_branch_day_register
|
UPDATE m_branch_day_register
|
||||||
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
|
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
|
||||||
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
||||||
WHERE office_id = ? AND business_date = ?
|
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
|
||||||
""",
|
""",
|
||||||
// Forced either way: a late close, or a close over a drawer nobody counted. The reason is
|
// Forced either way: a late close, or a close over a drawer nobody counted. The reason is
|
||||||
// stored whenever it was needed, not only when the date was in the past — a same-day
|
// stored whenever it was needed, not only when the date was in the past — a same-day
|
||||||
@@ -601,6 +664,10 @@ public class BranchDayRegisterApiResource {
|
|||||||
bookCash, difference, isLate || forcedOverCashiers,
|
bookCash, difference, isLate || forcedOverCashiers,
|
||||||
(isLate || forcedOverCashiers) ? overrideReason : null, remarks,
|
(isLate || forcedOverCashiers) ? overrideReason : null, remarks,
|
||||||
officeId, Date.valueOf(businessDate));
|
officeId, Date.valueOf(businessDate));
|
||||||
|
if (closed == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
|
||||||
|
"Business day " + businessDate + " is already closed.");
|
||||||
|
}
|
||||||
|
|
||||||
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
||||||
saveDenominationDetail(registerId, "CLOSING", denominations);
|
saveDenominationDetail(registerId, "CLOSING", denominations);
|
||||||
@@ -725,12 +792,30 @@ public class BranchDayRegisterApiResource {
|
|||||||
* {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier
|
* {@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
|
* 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.
|
* {@link VaultPositionService} for why the two are never allowed to mix.
|
||||||
|
*
|
||||||
|
* Replaces whatever this exact (register, leg, teller) already held rather than adding to it.
|
||||||
|
* Without that, a second call for the same snapshot -- a retry, a re-submitted close after an
|
||||||
|
* earlier attempt stalled partway, {@link #snapshotVaultDenominations} running twice for the
|
||||||
|
* same close -- leaves the old rows sitting alongside the new ones. Nothing downstream
|
||||||
|
* deduplicates by denomination before summing, so a vault's carried-forward opening balance (or
|
||||||
|
* a branch's own opening/closing figure) silently doubles instead of being replaced, exactly the
|
||||||
|
* kind of self-inflicted "the vault has more cash than the whole branch's book figure" gap this
|
||||||
|
* table exists to prevent, not cause.
|
||||||
*/
|
*/
|
||||||
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
|
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
|
||||||
final List<Map<String, Object>> denominations) {
|
final List<Map<String, Object>> denominations) {
|
||||||
if (denominations == null || denominations.isEmpty()) {
|
if (denominations == null || denominations.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The bare "? IS NULL" comparison (no column on either side to infer a type from) makes
|
||||||
|
// PostgreSQL's JDBC driver fail the whole statement with "could not determine data type of
|
||||||
|
// parameter" -- unlike "teller_id = ?", which gets its type from the teller_id column even
|
||||||
|
// when the bound value is null. Casting to BIGINT gives the standalone parameter a type of
|
||||||
|
// its own, independent of context.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
DELETE FROM m_branch_day_register_detail
|
||||||
|
WHERE register_id = ? AND leg = ? AND (teller_id = ? OR (teller_id IS NULL AND CAST(? AS BIGINT) IS NULL))
|
||||||
|
""", registerId, leg, tellerId, tellerId);
|
||||||
for (final Map<String, Object> row : denominations) {
|
for (final Map<String, Object> row : denominations) {
|
||||||
final int value = (int) row.get("denominationValue");
|
final int value = (int) row.get("denominationValue");
|
||||||
final int count = (int) row.get("count");
|
final int count = (int) row.get("count");
|
||||||
@@ -939,11 +1024,32 @@ public class BranchDayRegisterApiResource {
|
|||||||
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
|
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
|
||||||
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
|
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
|
||||||
// the branch's expected cash by exactly what had actually been paid out that day.
|
// the branch's expected cash by exactly what had actually been paid out that day.
|
||||||
|
// Scoped to this office through the cashier who paid it out. Unscoped, this summed EVERY
|
||||||
|
// branch's withdrawals into whichever branch happened to be closing: with 6-8 offices on
|
||||||
|
// these tenants, Head Office's expected cash was being reduced by cash another branch had
|
||||||
|
// paid over its own counter.
|
||||||
|
//
|
||||||
|
// Two id conventions have to be tolerated, because cashier_id here is NOT an m_cashiers id:
|
||||||
|
// these registers store the APPUSER id of the cashier who submitted (cashier_id = 4 =
|
||||||
|
// nitesh.cashier, while that user's m_cashiers row is id 1). Resolving it only as an
|
||||||
|
// m_cashiers id matched nothing and silently excluded every withdrawal from expected cash --
|
||||||
|
// reporting a phantom shortage of exactly what had been paid out. Accepting either reading
|
||||||
|
// keeps this correct whichever convention a given register follows.
|
||||||
|
//
|
||||||
|
// A row whose cashier is not set at all is still counted, matching the "OR office_id IS NULL"
|
||||||
|
// leniency the deposits query above applies: no cashier means no branch to attribute it to,
|
||||||
|
// and dropping it silently would understate the total instead.
|
||||||
final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
|
SELECT COALESCE(SUM(w.amount), 0) FROM m_clerk_withdrawal_request w
|
||||||
WHERE status = 'WITHDRAWN'
|
WHERE w.status = 'WITHDRAWN'
|
||||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
AND COALESCE(w.txn_date, CAST(w.submitted_at AS DATE)) = ?
|
||||||
""", BigDecimal.class, date));
|
AND (w.cashier_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = w.cashier_id AND t.office_id = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id = w.cashier_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId, officeId));
|
||||||
|
|
||||||
// Account closures pay cash over the same counter but live in their own register, so they
|
// Account closures pay cash over the same counter but live in their own register, so they
|
||||||
// were missing from this total entirely -- the branch's expected cash stayed high by exactly
|
// were missing from this total entirely -- the branch's expected cash stayed high by exactly
|
||||||
@@ -955,24 +1061,36 @@ public class BranchDayRegisterApiResource {
|
|||||||
// since a closure takes nothing out of the drawer until the cashier hands it over (a
|
// since a closure takes nothing out of the drawer until the cashier hands it over (a
|
||||||
// nil-balance closure never reaches a cashier at all, hence the closure_date fallback).
|
// nil-balance closure never reaches a cashier at all, hence the closure_date fallback).
|
||||||
final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(cashier_disbursed_amount), 0) FROM m_clerk_closure_request
|
SELECT COALESCE(SUM(cr.cashier_disbursed_amount), 0) FROM m_clerk_closure_request cr
|
||||||
WHERE status = 'CLOSED'
|
WHERE cr.status = 'CLOSED'
|
||||||
AND COALESCE(CAST(disbursed_at AS DATE), closure_date) = ?
|
AND COALESCE(CAST(cr.disbursed_at AS DATE), cr.closure_date) = ?
|
||||||
""", BigDecimal.class, date));
|
AND (cr.cashier_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = cr.cashier_id AND t.office_id = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id = cr.cashier_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId, officeId));
|
||||||
|
|
||||||
final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals);
|
final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals);
|
||||||
|
|
||||||
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
|
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch. Scoped
|
||||||
|
// to this office by the vault it came out of, for the same reason as the withdrawals above.
|
||||||
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
|
||||||
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
|
WHERE ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
|
||||||
""", BigDecimal.class, date));
|
AND (ct.from_teller_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = ct.from_teller_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
// Cash a cashier settled back to the vault at the end of the day.
|
// Cash a cashier settled back to the vault at the end of the day, scoped to the vault it
|
||||||
|
// went back into.
|
||||||
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
SELECT COALESCE(SUM(cs.settlement_amount), 0) FROM m_cash_settlement cs
|
||||||
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
|
WHERE cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
||||||
""", BigDecimal.class, date));
|
AND (cs.to_teller_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = cs.to_teller_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
|
final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
|
||||||
|
|
||||||
@@ -1025,8 +1143,26 @@ public class BranchDayRegisterApiResource {
|
|||||||
cashAccountId, cashAccountId);
|
cashAccountId, cashAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A caller-supplied officeId is checked against the caller's own office hierarchy, not taken on
|
||||||
|
* trust. Without that check, any user whose role name happens to contain "clerk"/"manager"/"head
|
||||||
|
* cashier" (see {@link #canOperateDay}) could open or close ANOTHER branch's business day, and
|
||||||
|
* read its register, close preview and cash-GL balance, just by naming its id in the request --
|
||||||
|
* the authority check confirmed they may operate A day, never WHOSE day.
|
||||||
|
* {@code validateAccessRights} is the same office-hierarchy gate the client and group write
|
||||||
|
* services already use, so a head-office user retains access to their branches' days and a
|
||||||
|
* branch user is confined to their own office and anything below it.
|
||||||
|
*/
|
||||||
private Long resolveOffice(final Long requested, final AppUser user) {
|
private Long resolveOffice(final Long requested, final AppUser user) {
|
||||||
if (requested != null) {
|
if (requested != null) {
|
||||||
|
final String hierarchy = jdbcTemplate
|
||||||
|
.query("SELECT hierarchy FROM m_office WHERE id = ?", (rs, n) -> rs.getString(1), requested).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (hierarchy == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.office.not.found",
|
||||||
|
"Office " + requested + " was not found.");
|
||||||
|
}
|
||||||
|
this.context.validateAccessRights(hierarchy);
|
||||||
return requested;
|
return requested;
|
||||||
}
|
}
|
||||||
return user.getOffice() != null ? user.getOffice().getId() : 1L;
|
return user.getOffice() != null ? user.getOffice().getId() : 1L;
|
||||||
|
|||||||
+18
-1
@@ -53,15 +53,28 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
|
// Gated like every sibling register in this flow (CREATE_CASHTRANSFER,
|
||||||
|
// CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER). Cash exchange had no check at all, so any
|
||||||
|
// authenticated user -- not just a cashier -- could write legs that move a drawer's
|
||||||
|
// denomination position. Permission seeded and granted to the existing cash-handling roles by
|
||||||
|
// migration 0539.
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHEXCHANGE");
|
||||||
|
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
||||||
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
||||||
: null;
|
: null;
|
||||||
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
||||||
|
// Defaulted, never left null: every as-of-date position query filters
|
||||||
|
// "ce.exchange_date <= CAST(? AS DATE)", which is false for NULL, so a null-dated exchange
|
||||||
|
// vanished from the cashier position and reconciliation reports while still counting in the
|
||||||
|
// undated stock query (DenominationBreakdownWriter.getCashierNetPosition). The drawer then
|
||||||
|
// read as holding the notes it had given away and none of the ones it had taken in. The
|
||||||
|
// sibling registers (cash transfer, settlement) already default the same way.
|
||||||
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
||||||
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
||||||
: null;
|
: DateUtils.getBusinessLocalDate();
|
||||||
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
||||||
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
||||||
? json.get("customerIdProof").getAsString() : null;
|
? json.get("customerIdProof").getAsString() : null;
|
||||||
@@ -153,6 +166,10 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Path("{id}/reverse")
|
@Path("{id}/reverse")
|
||||||
public String reverse(@PathParam("id") Long id, String body) {
|
public String reverse(@PathParam("id") Long id, String body) {
|
||||||
|
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
|
||||||
|
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
|
||||||
|
|
||||||
CashExchange exchange = repository.findById(id)
|
CashExchange exchange = repository.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
||||||
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
||||||
|
|||||||
+23
-7
@@ -77,6 +77,19 @@ public class CashierDepositRequestApiResource {
|
|||||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
||||||
|
|
||||||
|
// The temporary txn_id a pending deposit's denomination rows are tagged with (see
|
||||||
|
// savePendingDenomination) is this request's own id -- but m_savings_txn_denomination.txn_id has
|
||||||
|
// no reference-type discriminator, and a real m_savings_account_transaction id is drawn from the
|
||||||
|
// exact same small-integer range. Request #5 and transaction #5 coexisting is the ordinary case,
|
||||||
|
// not a rare one, so approve()'s "WHERE txn_id = <this request's id>" UPDATE (and reject()'s
|
||||||
|
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
||||||
|
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
||||||
|
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
||||||
|
// without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so
|
||||||
|
// a request still sitting PENDING continues to count in cashier-position reports -- the cash is
|
||||||
|
// physically in the drawer already, approval or not.
|
||||||
|
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -452,13 +465,15 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Denomination was already saved at submit time (cashier physically received cash then).
|
// Denomination was already saved at submit time (cashier physically received cash then).
|
||||||
// Now update the temporary txn_id (request id) to the real Fineract transaction id.
|
// Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to
|
||||||
// parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
|
// the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current)
|
||||||
|
// and hex (GL journal entry).
|
||||||
if (txnId != null) {
|
if (txnId != null) {
|
||||||
final Long realTxnId = parseTxnIdToLong(txnId);
|
final Long realTxnId = parseTxnIdToLong(txnId);
|
||||||
if (realTxnId != null) {
|
if (realTxnId != null) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id);
|
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId,
|
||||||
|
id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,9 +525,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
throw apiError(409, "This deposit request was already actioned by another manager.");
|
throw apiError(409, "This deposit request was already actioned by another manager.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse denomination — cashier physically returned cash to customer on rejection.
|
// Reverse denomination — cashier physically returned cash to customer on rejection. The
|
||||||
// The temporary denomination records (saved at submit time with txn_id = request id) are removed.
|
// temporary denomination records (saved at submit time with txn_id = offset request id -- see
|
||||||
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id);
|
// PENDING_DEPOSIT_TXN_ID_OFFSET) are removed.
|
||||||
|
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||||
}
|
}
|
||||||
@@ -875,7 +891,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId, savingsAccountId, accountNo, clientId, denomId,
|
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername, paymentTypeName
|
||||||
);
|
);
|
||||||
|
|||||||
+87
@@ -23,12 +23,14 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -44,6 +46,10 @@ public class CashInterCashierApiResource {
|
|||||||
private final CashInterCashierDetailRepository detailRepository;
|
private final CashInterCashierDetailRepository detailRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
// The same figure the Cashier Position screen shows, so a cashier cannot hand on notes the
|
||||||
|
// report says they are not holding -- CashSettlementApiResource depends on it the same way.
|
||||||
|
private final CashReportApiResource cashReportApiResource;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -85,6 +91,21 @@ public class CashInterCashierApiResource {
|
|||||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
|
||||||
|
// stock and settlement validates the cashier's; this register validated neither, so a cashier
|
||||||
|
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
|
||||||
|
// for ever, on every future date: the receiver's position, reconciliation and settlement
|
||||||
|
// allowance all rise by ₹1,00,000 of notes that do not exist, and the sender's reads
|
||||||
|
// -195x₹500 with nothing to net it back.
|
||||||
|
validateCashierHoldsDenominations(fromCashierId, denominations, transferDate);
|
||||||
|
|
||||||
|
// Both cashiers must sit under the same office. Nothing stopped a cross-branch inter-cashier
|
||||||
|
// transfer, which moves physical cash between two branches with no vault leg on either side
|
||||||
|
// -- so one branch's day-close is short and the other's is over, with no record of a transfer
|
||||||
|
// between them anywhere in either branch's register.
|
||||||
|
validateSameOffice(fromCashierId, toCashierId);
|
||||||
|
|
||||||
final BigDecimal total = sumTotal(denominations);
|
final BigDecimal total = sumTotal(denominations);
|
||||||
|
|
||||||
CashInterCashier header = new CashInterCashier();
|
CashInterCashier header = new CashInterCashier();
|
||||||
@@ -206,6 +227,72 @@ public class CashInterCashierApiResource {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same check, same source of truth and same message shape as
|
||||||
|
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
|
||||||
|
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
|
||||||
|
* position is left alone rather than refused, matching the settlement path: a cashier with no
|
||||||
|
* recorded position yet is a setup state, not an overdraft.
|
||||||
|
*/
|
||||||
|
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
||||||
|
final LocalDate transferDate) {
|
||||||
|
|
||||||
|
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
|
||||||
|
transferDate.toString());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||||
|
if (heldRows == null || heldRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Long, Long> held = new java.util.HashMap<>();
|
||||||
|
for (final Map<String, Object> row : heldRows) {
|
||||||
|
final Object id = row.get("denominationId");
|
||||||
|
final Object count = row.get("count");
|
||||||
|
if (id instanceof Number && count instanceof Number) {
|
||||||
|
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final JsonElement el : denominations) {
|
||||||
|
final JsonObject item = el.getAsJsonObject();
|
||||||
|
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 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 long available = held.getOrDefault(denominationId, 0L);
|
||||||
|
if (count > available) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.insufficient.cashier.denomination",
|
||||||
|
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
|
||||||
|
+ ", transferring " + count + ". Check the Cashier Position report first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both cashiers must belong to the same office, via their own teller. */
|
||||||
|
private void validateSameOffice(final Long fromCashierId, final Long toCashierId) {
|
||||||
|
final List<Map<String, Object>> offices = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id AS cashier_id, t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id IN (?, ?)
|
||||||
|
""", fromCashierId, toCashierId);
|
||||||
|
if (offices.size() < 2) {
|
||||||
|
// One of them has no teller/office resolvable -- leave it to the existing party checks
|
||||||
|
// rather than inventing a new failure here.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Object first = offices.get(0).get("office_id");
|
||||||
|
final Object second = offices.get(1).get("office_id");
|
||||||
|
if (first != null && second != null && !first.equals(second)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cross.office",
|
||||||
|
"Both cashiers must be in the same branch. Cash moving between branches has to go through"
|
||||||
|
+ " each branch's own vault, not a direct cashier-to-cashier transfer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveDetails(Long interCashierId, JsonArray items) {
|
private void saveDetails(Long interCashierId, JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
|
|||||||
+45
-1
@@ -47,6 +47,13 @@ public class CashReconciliationApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||||
|
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||||
|
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||||
|
// never corrected -- without this same filter here, this endpoint (unlike the cashier-position
|
||||||
|
// report) would count that phantom figure into a cashier's expected cash.
|
||||||
|
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||||
* Returns denomination-wise expected balance for a cashier.
|
* Returns denomination-wise expected balance for a cashier.
|
||||||
@@ -89,6 +96,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
|
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
@@ -118,6 +126,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
|
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
@@ -259,6 +268,14 @@ public class CashReconciliationApiResource {
|
|||||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||||
|
|
||||||
|
// Expected counts come from the server's own computation, never from the request body.
|
||||||
|
// Reading the client's expectedCount made the whole reconciliation self-certifying: a cashier
|
||||||
|
// Rs 40,000 short could post expectedCount == physicalCount on every row, and the record
|
||||||
|
// stored variance 0 / status OK. That row then satisfied the mandatory reconciliation
|
||||||
|
// precondition on settlement, so the shortage never surfaced anywhere in the system. The
|
||||||
|
// physical count is the cashier's to declare; what the books expect is not.
|
||||||
|
final Map<Long, Long> serverExpected = expectedCountsByDenomination(cashierId, reconDate);
|
||||||
|
|
||||||
BigDecimal expectedTotal = BigDecimal.ZERO;
|
BigDecimal expectedTotal = BigDecimal.ZERO;
|
||||||
BigDecimal physicalTotal = BigDecimal.ZERO;
|
BigDecimal physicalTotal = BigDecimal.ZERO;
|
||||||
List<CashReconciliationDetail> details = new ArrayList<>();
|
List<CashReconciliationDetail> details = new ArrayList<>();
|
||||||
@@ -266,7 +283,10 @@ public class CashReconciliationApiResource {
|
|||||||
for (JsonElement el : denominations) {
|
for (JsonElement el : denominations) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||||
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
|
final Long denomIdForExpected = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||||
|
? item.get("denominationId").getAsLong() : null;
|
||||||
|
int expectedCount = denomIdForExpected != null
|
||||||
|
? serverExpected.getOrDefault(denomIdForExpected, 0L).intValue() : 0;
|
||||||
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
||||||
int varianceCount = physicalCount - expectedCount;
|
int varianceCount = physicalCount - expectedCount;
|
||||||
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
||||||
@@ -348,6 +368,30 @@ public class CashReconciliationApiResource {
|
|||||||
rs.getLong("total_count")
|
rs.getLong("total_count")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's own expected note/coin counts for a cashier as of a date, keyed by denomination
|
||||||
|
* id. Delegates to {@link #getExpected} rather than re-deriving the eight legs: two
|
||||||
|
* implementations of "what should this drawer hold" drift apart, and the one place that matters
|
||||||
|
* is the record a settlement is later allowed on the strength of.
|
||||||
|
*/
|
||||||
|
private Map<Long, Long> expectedCountsByDenomination(final Long cashierId, final LocalDate asOfDate) {
|
||||||
|
final Map<Long, Long> expected = new java.util.HashMap<>();
|
||||||
|
final JsonObject computed = JsonParser.parseString(getExpected(cashierId, asOfDate.toString())).getAsJsonObject();
|
||||||
|
if (!computed.has("denominations") || !computed.get("denominations").isJsonArray()) {
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
for (final JsonElement el : computed.getAsJsonArray("denominations")) {
|
||||||
|
final JsonObject row = el.getAsJsonObject();
|
||||||
|
if (!row.has("denominationId") || row.get("denominationId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long count = row.has("expectedCount") && !row.get("expectedCount").isJsonNull()
|
||||||
|
? row.get("expectedCount").getAsLong() : 0L;
|
||||||
|
expected.merge(row.get("denominationId").getAsLong(), count, Long::sum);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
||||||
for (Object[] row : rows) {
|
for (Object[] row : rows) {
|
||||||
Long denomId = (Long) row[0];
|
Long denomId = (Long) row[0];
|
||||||
|
|||||||
+8
-2
@@ -76,14 +76,20 @@ public class CashReportApiResource {
|
|||||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
||||||
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
||||||
//
|
//
|
||||||
|
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
|
||||||
|
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
|
||||||
|
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
|
||||||
|
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
|
||||||
|
// everything counted after it) out of the cashier's position.
|
||||||
|
//
|
||||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
||||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id --
|
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id --
|
||||||
// is untouched by this test and still handled by the id-magnitude test above.
|
// is untouched by this test and still handled by the id-magnitude test above.
|
||||||
+ "AND NOT EXISTS ("
|
+ "AND NOT EXISTS ("
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
||||||
+ " WHERE mt.id = std.txn_id"
|
+ " WHERE mt.id = std.txn_id"
|
||||||
+ " AND mt.amount <> (SELECT SUM(sd.count * sd.denomination_value)"
|
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
|
||||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) ";
|
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
* Day book — every voucher raised on one date, in one list, newest first.
|
||||||
|
|||||||
+20
-8
@@ -89,14 +89,26 @@ public class VaultPositionService {
|
|||||||
""", officeId, dateFilter, tellerId);
|
""", officeId, dateFilter, tellerId);
|
||||||
String anchorLeg = "OPENING_VAULT";
|
String anchorLeg = "OPENING_VAULT";
|
||||||
if (anchorRows.isEmpty()) {
|
if (anchorRows.isEmpty()) {
|
||||||
anchorLeg = "OPENING";
|
// The branch-total OPENING leg mixes the vault with whatever every cashier under it
|
||||||
anchorRows = jdbcTemplate.queryForList("""
|
// holds, so it is only a defensible stand-in for a vault when the office has exactly one
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
// teller. With two, EVERY teller without a vault-scoped row of its own would anchor on
|
||||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
// the same office-wide breakdown, and the frontend's Branch Total -- which sums each
|
||||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
// teller's vault -- would report roughly twice the branch's actual cash. Worse, a close
|
||||||
WHERE d.register_id = r.id AND d.leg = 'OPENING')
|
// then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open
|
||||||
ORDER BY r.business_date DESC LIMIT 1
|
// carries it forward, making the phantom permanent. A second teller with no history of
|
||||||
""", officeId, dateFilter);
|
// its own genuinely holds nothing, and returning no breakdown says so honestly.
|
||||||
|
final Integer tellerCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?",
|
||||||
|
Integer.class, officeId);
|
||||||
|
if (tellerCount != null && tellerCount == 1) {
|
||||||
|
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()) {
|
if (anchorRows.isEmpty()) {
|
||||||
|
|||||||
+9
@@ -63,7 +63,16 @@ public class CashSettlementApiResource {
|
|||||||
private final CashReportApiResource cashReportApiResource;
|
private final CashReportApiResource cashReportApiResource;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// Transactional so the reconciliation precondition and the duplicate-settlement check below are
|
||||||
|
// read in the same transaction that writes the settlement. They used to run in autocommit while
|
||||||
|
// the insert happened later inside its own TransactionTemplate, leaving a window where a
|
||||||
|
// double-clicked Submit passed both checks twice and wrote two settlements for the same
|
||||||
|
// cashier/date -- each subtracted cumulatively, so the drawer read permanently negative by the
|
||||||
|
// settlement amount. Migration 0540 adds the unique index that makes the race impossible rather
|
||||||
|
// than merely unlikely; this annotation is what stops the two halves of the check-then-act from
|
||||||
|
// being separated by another request's commit.
|
||||||
@POST
|
@POST
|
||||||
|
@Transactional
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
||||||
|
|
||||||
|
|||||||
+43
-10
@@ -187,12 +187,26 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
activityLog, Timestamp.from(Instant.now()), requestType
|
activityLog, Timestamp.from(Instant.now()), requestType
|
||||||
);
|
);
|
||||||
|
|
||||||
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
|
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
|
||||||
// bank transfer both move money out of the source side, so they read as a debit.
|
// money out of the source side, so it reads as a debit — but only when the TXN TP. the
|
||||||
final String voucherPrefix = "COLLECTION".equals(requestType)
|
// clerk actually picked is Cash. That dropdown is the real-world signal for whether notes
|
||||||
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
// cross the counter, the same way it already governs every teller screen in this system
|
||||||
|
// (isCashPayment): a clerk who selects Transfer, Cheque, DD or NEFT/RTGS is saying no cash
|
||||||
|
// moves for this request, whatever GL it happens to post against, so it belongs in the
|
||||||
|
// Journal series instead of being counted as real cash received or paid on My Vouchers.
|
||||||
|
final boolean isCashPayment = paymentTypeId != null && Boolean.TRUE.equals(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId));
|
||||||
|
final String voucherPrefix = !isCashPayment ? VoucherService.PREFIX_JOURNAL
|
||||||
|
: "COLLECTION".equals(requestType) ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
||||||
|
// The voucher's own TYPE column used to be hard-coded to VoucherService.TYPE_DISBURSEMENT
|
||||||
|
// for every request this register raises, so My Vouchers showed "DISBURSEMENT" even for a
|
||||||
|
// Collection or a Fund Transfer the clerk had explicitly picked a different TYPE for on the
|
||||||
|
// entry screen — the one place this register's actual four-way distinction ought to have
|
||||||
|
// been visible was throwing it away. Stored as the clerk's own requestType instead, in the
|
||||||
|
// same words the TYPE dropdown already uses.
|
||||||
|
final String voucherTypeLabel = voucherTypeLabelFor(requestType);
|
||||||
final String voucherNo = voucherService.createVoucher(
|
final String voucherNo = voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||||
newId, txnAmount, currentUser, voucherPrefix);
|
newId, txnAmount, currentUser, voucherPrefix);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||||
@@ -1010,13 +1024,26 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
||||||
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
|
* voucher so the two numbering schemes cannot drift apart.
|
||||||
* the debited accounts, so it reads as a debit: DR/2108/0001.
|
*
|
||||||
|
* Numbered Journal rather than Debit: both sides of a bank transfer are member accounts, and
|
||||||
|
* the money never touches Cash in Hand or the vault either way (see the "no CashVault entry
|
||||||
|
* created anywhere" comments where these accounts are debited/credited directly). A DR number
|
||||||
|
* would have read as real cash paid out, double-counting the figure in My Vouchers on top of
|
||||||
|
* the day's actual Payment total.
|
||||||
*/
|
*/
|
||||||
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
||||||
return voucherService.createVoucher(
|
return voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
|
"Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG,
|
||||||
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
|
logId, amount, currentUser, VoucherService.PREFIX_JOURNAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The TYPE column's label, in the same words the clerk's own TYPE dropdown uses for it. */
|
||||||
|
private static String voucherTypeLabelFor(final String requestType) {
|
||||||
|
if ("COLLECTION".equals(requestType)) return "Collection";
|
||||||
|
if ("BANK_TRANSFER".equals(requestType)) return "Bank Transfer";
|
||||||
|
if ("FUND_TRANSFER".equals(requestType)) return "Fund Transfer";
|
||||||
|
return "Disbursement";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1250,7 +1277,13 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
// This disbursement/collection request already raised its own voucher at submission and
|
||||||
|
// attaches every leg to it in addDisbursementVoucherLines() after approval succeeds — without
|
||||||
|
// withOwnVoucher() the plain savings withdrawal command below raises a second, orphaned
|
||||||
|
// voucher of its own for the same money (see VoucherService.withOwnVoucher's javadoc), which
|
||||||
|
// is what was double-counting a Collection's amount in both Received and Payments on My Vouchers.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -40,6 +40,14 @@ public class DenominationBreakdownWriter {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
||||||
|
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
||||||
|
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
||||||
|
// never corrected -- without this same filter here, a cashier's computed stock (and the
|
||||||
|
// insufficient-stock check that guards a withdrawal against it) would count that phantom figure
|
||||||
|
// as real notes and coins in the drawer.
|
||||||
|
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,8 +134,17 @@ public class DenominationBreakdownWriter {
|
|||||||
"Denomination breakdown validation errors exist.", errors);
|
"Denomination breakdown validation errors exist.", errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Grand total must match the transaction amount
|
// 5. Grand total must match the transaction amount, to within a rupee.
|
||||||
if (expectedAmount != null && computedTotal.compareTo(expectedAmount) != 0) {
|
//
|
||||||
|
// Exact equality made a whole class of legitimate transaction impossible to complete: an
|
||||||
|
// interest-bearing withdrawal or closure settles on a paisa figure (₹12,998.56), Indian cash
|
||||||
|
// has no paisa coins, so NO set of notes can ever sum to it and every breakdown the cashier
|
||||||
|
// entered was rejected. Sub-rupee is therefore accepted as the rounding it is -- the same
|
||||||
|
// tolerance CashReportApiResource's own breakdown check applies on the reporting side, so
|
||||||
|
// the two agree instead of one accepting what the other rejects. A genuinely mis-keyed
|
||||||
|
// breakdown (₹1,60,000 of notes against a ₹918 deposit) is off by far more than a rupee and
|
||||||
|
// is still refused.
|
||||||
|
if (expectedAmount != null && computedTotal.subtract(expectedAmount).abs().compareTo(BigDecimal.ONE) >= 0) {
|
||||||
throw new PlatformDataIntegrityException(
|
throw new PlatformDataIntegrityException(
|
||||||
"error.msg.denomination.total.mismatch",
|
"error.msg.denomination.total.mismatch",
|
||||||
label + " denomination breakdown total (" + computedTotal
|
label + " denomination breakdown total (" + computedTotal
|
||||||
@@ -277,6 +294,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
|
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -300,6 +318,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
|
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -309,7 +328,13 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
|
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
|
||||||
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
|
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
|
||||||
+ " GROUP BY ced.denomination_id"
|
+ " GROUP BY ced.denomination_id"
|
||||||
+ " ) raw GROUP BY denomination_id HAVING SUM(net_count) > 0"
|
// No "HAVING SUM(net_count) > 0" here: dropping the denominations that net negative
|
||||||
|
// reports MORE cash than the drawer holds, because the shortfall they represent is
|
||||||
|
// silently discarded instead of subtracted. Net -20x₹500 with +30x₹100 is -₹7,000,
|
||||||
|
// not +₹3,000 -- and this figure is what validateCashierHasTotal lets a withdrawal
|
||||||
|
// through on, so the optimistic version hands out money that is not there. It also
|
||||||
|
// disagreed with getCashierNetPosition below, which has never filtered them.
|
||||||
|
+ " ) raw GROUP BY denomination_id"
|
||||||
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
|
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
|
||||||
|
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
|
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
|
||||||
@@ -390,6 +415,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
|
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -413,6 +439,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
|
+ " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")"
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
|
|||||||
+30
-3
@@ -785,7 +785,15 @@ public class VoucherVerificationApiResource {
|
|||||||
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
||||||
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
|
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
|
||||||
(v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me,
|
(v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me,
|
||||||
(ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me
|
(ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me,
|
||||||
|
-- Whether either side of the voucher is the till itself — same test as the
|
||||||
|
-- register listing below, and for the same reason: a Collection/Disbursement
|
||||||
|
-- request is numbered in the CR/DR series (it debits or credits a member's
|
||||||
|
-- own account), but when its other leg is a GL account rather than the vault,
|
||||||
|
-- no cash actually crossed the counter. Counting it under Received or Payment
|
||||||
|
-- read as double-counting the exact figure already shown on the Journal-free
|
||||||
|
-- day totals, when it was really a book entry between two ledgers.
|
||||||
|
till.touched AS till_touched
|
||||||
FROM m_voucher v
|
FROM m_voucher v
|
||||||
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
@@ -799,6 +807,14 @@ public class VoucherVerificationApiResource {
|
|||||||
SELECT party_name, account_no FROM m_voucher_line
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||||
ORDER BY line_no LIMIT 1) cr ON TRUE
|
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRUE AS touched FROM m_voucher_line cl
|
||||||
|
WHERE cl.voucher_no = v.voucher_no
|
||||||
|
AND (cl.gl_account_id IN (
|
||||||
|
SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa
|
||||||
|
WHERE fa.financial_activity_type IN (101, 102))
|
||||||
|
OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand'))
|
||||||
|
LIMIT 1) till ON TRUE
|
||||||
WHERE v.created_at >= ? AND v.created_at < ?
|
WHERE v.created_at >= ? AND v.created_at < ?
|
||||||
AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?)
|
AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?)
|
||||||
ORDER BY v.created_at DESC, v.voucher_no DESC
|
ORDER BY v.created_at DESC, v.voucher_no DESC
|
||||||
@@ -816,11 +832,23 @@ public class VoucherVerificationApiResource {
|
|||||||
for (final Map<String, Object> r : rows) {
|
for (final Map<String, Object> r : rows) {
|
||||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||||
final String voucherNo = (String) r.get("voucher_no");
|
final String voucherNo = (String) r.get("voucher_no");
|
||||||
|
final int lineCount = ((Number) r.get("line_count")).intValue();
|
||||||
// The series the number was issued in is the side: a register's own prefix is the one
|
// The series the number was issued in is the side: a register's own prefix is the one
|
||||||
// thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type
|
// thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type
|
||||||
// would have to keep a list of types in step with the ones that issue them.
|
// would have to keep a list of types in step with the ones that issue them.
|
||||||
final String side = voucherNo == null ? null
|
final String rawSide = voucherNo == null ? null
|
||||||
: voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV";
|
: voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV";
|
||||||
|
// A CR/DR voucher whose lines never touch the till (the same test bookBreakdown() uses
|
||||||
|
// below) is a book entry between two ledgers that happened to be numbered in the cash
|
||||||
|
// series — a Collection/Disbursement request posted against a GL account rather than the
|
||||||
|
// vault, e.g. a savings account debited straight into a Sundry GL account. Reading its
|
||||||
|
// prefix at face value counted that figure a second time, on top of whatever Journal
|
||||||
|
// already showed, as if real cash had crossed the counter. A voucher with no lines yet
|
||||||
|
// (raised but not actioned) is left alone — there is nothing to test the till against,
|
||||||
|
// and it is still real expected cash, not yet a book entry either way.
|
||||||
|
final boolean tillTouched = Boolean.TRUE.equals(r.get("till_touched"));
|
||||||
|
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
|
||||||
|
? "JV" : rawSide;
|
||||||
|
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
if ("CR".equals(side)) {
|
if ("CR".equals(side)) {
|
||||||
@@ -856,7 +884,6 @@ public class VoucherVerificationApiResource {
|
|||||||
m.put("debitAccount", r.get("debit_account"));
|
m.put("debitAccount", r.get("debit_account"));
|
||||||
m.put("creditParty", r.get("credit_party"));
|
m.put("creditParty", r.get("credit_party"));
|
||||||
m.put("creditAccount", r.get("credit_account"));
|
m.put("creditAccount", r.get("credit_account"));
|
||||||
final int lineCount = ((Number) r.get("line_count")).intValue();
|
|
||||||
m.put("extraLines", Math.max(0, lineCount - 2));
|
m.put("extraLines", Math.max(0, lineCount - 2));
|
||||||
m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED");
|
m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED");
|
||||||
out.add(m);
|
out.add(m);
|
||||||
|
|||||||
@@ -65,4 +65,6 @@
|
|||||||
<include file="parts/0536_sync_job_identity_sequence.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0536_sync_job_identity_sequence.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0537_seed_delete_office_permission.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0537_seed_delete_office_permission.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0538_add_branch_pincode.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0538_add_branch_pincode.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0539_seed_cash_exchange_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0540_unique_active_cash_settlement_per_cashier_day.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Every other register in the cashier's cash flow is permission-gated: CREATE_CASHTRANSFER,
|
||||||
|
CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER, plus their READ and REVERSE siblings. Cash
|
||||||
|
exchange (a customer swapping notes for other notes over the counter) shipped without any of
|
||||||
|
them, so CashExchangeApiResource had nothing to check and any authenticated user could write
|
||||||
|
or reverse exchange legs, which feed straight into every cashier-position and reconciliation
|
||||||
|
figure. These are the missing permissions, named and grouped exactly like the siblings.
|
||||||
|
|
||||||
|
Seeded and granted in the same changeset on purpose: adding the permission check in code
|
||||||
|
without the grant would lock the feature out for the very cashiers who use it, so the grant
|
||||||
|
goes to precisely the roles that already hold CREATE_CASHTRANSFER (CASHIER, Clerk, and
|
||||||
|
anything else configured with it), rather than to a hard-coded role name that may not exist
|
||||||
|
in a given tenant. -->
|
||||||
|
<changeSet id="0539_seed_cash_exchange_permissions" author="cbs-nitesh">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_CASHEXCHANGE'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="cashier"/>
|
||||||
|
<column name="code" value="CREATE_CASHEXCHANGE"/>
|
||||||
|
<column name="entity_name" value="CASHEXCHANGE"/>
|
||||||
|
<column name="action_name" value="CREATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="cashier"/>
|
||||||
|
<column name="code" value="READ_CASHEXCHANGE"/>
|
||||||
|
<column name="entity_name" value="CASHEXCHANGE"/>
|
||||||
|
<column name="action_name" value="READ"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="cashier"/>
|
||||||
|
<column name="code" value="REVERSE_CASHEXCHANGE"/>
|
||||||
|
<column name="entity_name" value="CASHEXCHANGE"/>
|
||||||
|
<column name="action_name" value="REVERSE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0539_grant_cash_exchange_permissions" author="cbs-nitesh">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT DISTINCT existing.role_id, newperm.id
|
||||||
|
FROM m_role_permission existing
|
||||||
|
JOIN m_permission sibling ON sibling.id = existing.permission_id
|
||||||
|
CROSS JOIN m_permission newperm
|
||||||
|
WHERE sibling.code = 'CREATE_CASHTRANSFER'
|
||||||
|
AND newperm.code IN ('CREATE_CASHEXCHANGE', 'READ_CASHEXCHANGE', 'REVERSE_CASHEXCHANGE')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp
|
||||||
|
WHERE rp.role_id = existing.role_id AND rp.permission_id = newperm.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- CashSettlementApiResource.create() rejects a second settlement for the same cashier and date
|
||||||
|
with a COUNT(*) check, but a check in application code is only as good as the transaction it
|
||||||
|
runs in: a double-clicked Submit had both requests pass the count before either had
|
||||||
|
committed, and both settlements were written. Each is subtracted from that cashier's
|
||||||
|
cumulative position, so the drawer then reads permanently short by the settlement amount and
|
||||||
|
no screen explains why.
|
||||||
|
|
||||||
|
A partial unique index is the durable half of that fix (the resource is now transactional
|
||||||
|
too). Partial rather than plain, because a reversed settlement must not block a corrected
|
||||||
|
one being entered for the same day: is_reversed IS NULL is treated as not-reversed, matching
|
||||||
|
every query in this flow that reads it as "COALESCE(is_reversed, false) = false".
|
||||||
|
|
||||||
|
Created only when no duplicate already exists, so a tenant that accumulated one before this
|
||||||
|
migration is not blocked from starting up: the index cannot be created there until the
|
||||||
|
duplicates are dealt with, which is a data decision for that bank to make (which of the two
|
||||||
|
settlements was real), not something to silently pick a winner for here. -->
|
||||||
|
<changeSet id="0540_unique_active_cash_settlement_per_cashier_day" author="cbs-nitesh">
|
||||||
|
<preConditions onFail="MARK_RAN" onFailMessage="Skipped: duplicate active settlements already exist for a cashier/date, or the index is already present. Resolve the duplicates, then re-run.">
|
||||||
|
<and>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT from_cashier_id, settlement_date
|
||||||
|
FROM m_cash_settlement
|
||||||
|
WHERE COALESCE(is_reversed, false) = false
|
||||||
|
AND from_cashier_id IS NOT NULL
|
||||||
|
GROUP BY from_cashier_id, settlement_date
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) dupes
|
||||||
|
</sqlCheck>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM pg_indexes
|
||||||
|
WHERE tablename = 'm_cash_settlement'
|
||||||
|
AND indexname = 'uq_cash_settlement_active_per_cashier_day'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
CREATE UNIQUE INDEX uq_cash_settlement_active_per_cashier_day
|
||||||
|
ON m_cash_settlement (from_cashier_id, settlement_date)
|
||||||
|
WHERE COALESCE(is_reversed, false) = false AND from_cashier_id IS NOT NULL
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>DROP INDEX IF EXISTS uq_cash_settlement_active_per_cashier_day</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user