Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 6cfa7ef3d1 feat(approvals): live Approvals badge counts over one SSE stream
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
One endpoint now answers how many requests wait on every Approval Center tab,
so the screen no longer makes a call per tab.

- GET /v1/approvals/counts: snapshot, numbers only (held 3s, fresh=true skips).
- GET /v1/approvals/counts/stream: Server-Sent Events. A `counts` event is
  written the moment the numbers change; one shared 1s check serves every open
  screen and callers counted alike share one query. Keep-alive ping every 15s,
  stream recycled every 5 minutes.
- All 26 tabs are counted by one statement of scalar sub-selects, each the same
  table/status/office scope/exclusions as the tab's own API. A tab the caller may
  not see is not asked (permissions mirrored, disbursement for managers only).
- Layered: thin API resource, ApprovalCountsReadPlatformService (+Impl),
  ApprovalCountsStreamService, ApprovalCounterRegistry (one line per tab).
- Adds jersey-media-sse (SseEventSink / Sse).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:14 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef feat(vouchers): voucher correction for cash deposits posted to the wrong account
A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back:
the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a
manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the
same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it.

- VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction,
  approve/reject correction, close request, voided transactions.
- The wrong account's balance is checked when the cashier enters the correction and again at approval.
- A cashier whose customer has already used the money closes the request and deposits separately.
- Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for
  a manager or head cashier other than the maker.
- Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher,
  since the correction status is read from m_voucher_correction alone.
- Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status
  and leave a revoked voucher out of their totals, so its cash is not counted twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:21 +05:30
niteshandClaude Sonnet 5.5 04d1eb20f0 feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
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 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 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 - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same
maker-checker path as a savings withdrawal:
- the request carries gl_account_id, contra_gl_id, office_id and currency_code
  (new nullable columns, changeset 0600) instead of a savings account;
- at submit it is validated: the paying GL must be a mapped cash vault GL and the
  slip's own GL must not be a cash GL;
- at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry;
- the cashier pays it against its token, and the denomination and voucher lines are
  recorded under the journal entry's id. KYC debit checks are skipped for GL requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:01 +05:30
niteshandClaude Sonnet 5.5 da1e21f2ca feat(security): block undo/reverse of posted transactions at API level
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 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 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 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
Rejects journal entry reverse, savings/current undo and reverse, fixed and recurring deposit
undo, and loan transaction undo (adjust with zero amount) with a clear error, so posted entries
cannot be reversed by calling the API directly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:42:01 +05:30
niteshandClaude Sonnet 5.5 b1eea68b17 feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and branch position API
- Cash exchange: customer name/mobile/PAN (PAN mandatory above Rs 50,000), denomination validation
  against m_denomination_master, cashier required; Liquibase 0599 adds the three columns.
- Inter-cashier: sender must be the logged-in cashier, cash-allocated validation, new
  cashier-summary endpoint.
- Reconciliation restricted to the logged-in cashier's own records.
- New cashreport/branch-position endpoint (vault + cashier drawers, denomination-wise).
- Customer search returns the KYC PAN.
- Reverse endpoints removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:42:00 +05:30
niteshandClaude Opus 5.5 539f6cd0f4 fix(accounting): allow unbalanced legs when defining opening balances
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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (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-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 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
Defining opening balances was rejected by the debit/credit balance
check that ordinary journal entries go through. For opening balances
that check does not hold: every leg is posted against the opening
balance contra account, so the books always balance, and the submitted
legs legitimately differ by the opening balance of the locked,
denomination-managed cash account, which is already posted and can
never be part of the request.

- validateBusinessRulesForJournalEntries takes a requireBalanced flag.
  Ordinary journal entries keep the full check (requireBalanced = true).
- defineOpeningBalance passes false: it still needs at least one leg
  and an account and amount on every leg, but no longer requires debits
  and credits to be present on both sides or to add up.

Verified with :fineract-provider:compileJava.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:00:39 +05:30
niteshandClaude Opus 5.5 0afcf059a8 Merge latest feature/advanceLoanProduct into feature/merge-dds-advance-loan-into-dev-latest
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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (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 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Brings Sonali's four new commits (7-12 Oct):
- Loans A/C extension fields (collateral, eligibility, repayment, rate
  source, prepayment)
- Maker-checker for Delinquency Ranges and Buckets
- Loan interest rate register product master and GL account seeding
- Her merge of dev-latest (7 Oct)

Conflict resolved:
- changelog-tenant.xml: kept both. 0594_loan_rate_register_product_
  master, 0595_seed_loan_rate_register_gl_accounts,
  0596_enable_delinquency_maker_checker and 0597_add_loan_product_
  loans_ext_fields go with the earlier advance loan changelogs, before
  the DDS block that stays last. No file name is shared with
  dev-latest's 0594-0597 files.

0581_enable_loan_rate_register_maker_checker.xml: taken as on
feature/advanceLoanProduct. It edits the existing 0581a/0581b
changesets (CBSLOANBASEINTERESTRATE -> LOANBASEINTERESTRATE) and adds
0581c to rename rows already seeded; all three carry
<validCheckSum>ANY</validCheckSum>, so databases that ran the old
version still validate.

CommandSourceService: Sonali's LOANBASEINTERESTRATE, DELINQUENCY_BUCKET
and DELINQUENCY_RANGE checks sit alongside the DDS isDdsEntity check.

Nothing from dev-latest, DDS or Sonali is removed; no file deleted; no
DB password change. Verified with :fineract-provider:compileJava.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:29:42 +05:30
sonaliandClaude Sonnet 5 2d10ec60f8 feat(loan-products): add Loans A/C extension fields (collateral, eligibility, repayment, rate source, prepayment)
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 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 E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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 - 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (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
Stores the Loans A/C (EMI) wizard's fields that have no home in
Fineract's core loan-product schema on the same per-product satellite
used for Advances: collateral/guarantor rules, borrower eligibility,
disbursement, repayment mode, rate source / floating reset, and
part-prepayment/foreclosure. Columns are loan_-prefixed so they never
collide with the Advances columns on the shared table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:50:47 +05:30
sonaliandClaude Sonnet 5 14973dfd5e feat(delinquency): add maker-checker for Delinquency Ranges and Buckets
Range/bucket create, update and delete now route through the standard
command-source approval pipeline, since a bucket drives the SMA/NPA
classification of every loan product linked to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:50:29 +05:30
niteshandClaude Opus 5.5 f96705baa4 Merge branch 'feature/advanceLoanProduct' into merge/dds-advance-loan-into-dev-latest
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 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 - 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-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 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
Adds the Advance Loan Product backend on top of the DDS merge of
dev-latest:
- Advance loan product ext entity, API and Liquibase
  (LoanProductAdvanceExtService; loan product API accepts and returns
  "advanceExtensions")
- Fees-Penalty-Interest-Principal repayment transaction processor,
  enabled by fineract.loan.transactionprocessor.
  fees-penalties-interest-principal.enabled (default true)
- Loan interest rate register backend

Conflict resolved:
- changelog-tenant.xml: kept both. The four advance loan changelogs
  (0571_create_loan_product_advance_ext, 0572_add_loan_product_advance_
  ext_fields, 0580_create_loan_rate_register_tables,
  0581_enable_loan_rate_register_maker_checker) go just before the DDS
  block, which its comment says stays last. Their numbers overlap
  dev-latest files but no file name is shared.

Not taken from feature/advanceLoanProduct:
- Commit b72657703 changed the default DB password in
  application.properties to a UAT database password. That merged
  without a conflict; the dev-latest password lines are kept so local
  setups keep working and the UAT password does not go into the
  repository. The new transaction processor property from the same
  file is kept.

Nothing from dev-latest or DDS is removed: no file deleted; only
LoanProductsApiResource (one supported parameter added),
LoanProductConfiguration (one constructor argument added) and
application.properties change existing lines. CommandWrapperBuilder,
changed on both sides, has no duplicate methods.

Verified with :fineract-provider:compileJava.

Branch renamed from merge/dds-into-dev-latest, since it now carries
both DDS and Advance Loan Product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:29:13 +05:30
niteshandClaude Opus 5.5 9d6ef18929 Merge branch 'feature/dds' into merge/dds-into-dev-latest
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 - 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 - MariaDB / test (test-core-5) (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 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
Brings the DDS (Daily Deposit Scheme) backend from feature/dds onto the
current dev-latest:
- Agent settlement (Agent Tran), Close Party account, reports
  (Supplementary-DDS, Master Data Export, Close Register), Party-Savings
  link, savings product category field
- Collection entries held before posting, per-agent GL accounts,
  maturity auto close
- Vouchers, maker-checker approvals, payout & close, Sundry GL, closure
  tab and fixes
- POST_DDS_INTEREST job

No content conflicts. No dev-latest file is removed; only three
dev-latest lines are extended with a DDS condition:
- CommandSourceService: DDS entities join ROLE in the maker-checker
  entity check (isDdsEntity).
- PortfolioCommandSourceWritePlatformServiceImpl: a DDS entry can never
  be approved by the user who made it, even a checker super user. The
  rule for every other entity is unchanged.
- SavingsProductDataValidator: productCategory accepted as a savings
  product parameter.

Files changed on both branches and auto-merged: CommandWrapperBuilder,
CommandSourceService, JobName, SavingsApiConstants, SavingsProductData,
SavingsProduct, FixedDepositProductData, RecurringDepositProductData,
DepositProductReadPlatformServiceImpl. Checked for duplicate methods,
fields and enum names.

Liquibase: 55 DDS changelogs (0470-0525) included as one block at the
end of changelog-tenant.xml. Their numbers overlap dev-latest's files
but no file name is shared, and Liquibase tracks changesets by file
path, so nothing clashes. They keep their names so databases that
already ran them do not run them again. No permission code, job name
or configuration name is inserted by both branches.

Verified with :fineract-provider:compileJava. The full compileJava
fails only in fineract-client (generated CKYC, committee meeting and
election APIs), which feature/dds does not touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:11:14 +05:30
niteshandClaude Opus 5.5 936e21dcf6 Merge branch 'feature/fd-interest-posting' into dev-latest
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 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-5) (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 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
Brings the FD interest posting work into dev-latest:
- FD interest posting approval with pooled GL postings, posting period
  shown per line, maturity postings surfaced, double-posting guard
- FD maturity approval report added and then reverted (maturity now
  runs directly through the scheduler, no approval step)
- Pending user approval encodes the password and emails it
- New-tenant user personalisation, with the 'system' username kept so
  scheduler jobs and COB can still find the system user

Conflicts resolved:
- SavingsAccountReadPlatformServiceImpl#retrieveAllSavingsDataForInterestPosting:
  kept both exclusions. Recurring deposits (deposit_type_enum 300) stay
  out of the generic interest posting job (dev-latest), and fixed
  deposits are also left out when FD interest posting approval is
  enabled (feature branch), so FD interest is not paid twice.
  Comments from both sides merged.
- final-changelog-tenant.xml: kept dev-latest's 0592-0598 TDS and
  recurring deposit changelogs and added
  0593_fd_interest_posting_approval.xml after them. The file keeps its
  name although its number clashes with 0593_tds_...: Liquibase tracks
  changesets by file path, so renaming it would re-run it on databases
  that already applied it. Changeset ids in the two files differ.

Verified with :fineract-provider:compileJava.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:23:55 +05:30
niteshandClaude Opus 5.5 aceea89c5c fix(tenants): keep the 'system' username when provisioning a new tenant
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (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 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 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
Problem
Commit 3d3ce92e9 personalised the three users seeded into a brand-new
tenant database and renamed user id 2 from 'system' to
'<tenant>_system' (for example 'default_system'). Fineract looks this
user up by the fixed name AppUserConstants.SYSTEM_USER_NAME ('system')
through AppUserRepositoryWrapper.fetchSystemUser(), so on every fresh
tenant:
- every scheduler job failed once a minute with
  "UserNotFoundException: User with name 'system' does not exist"
  (seen with the Send Asynchronous Events job);
- COB initialisation, savings escheat, current account closure and the
  stuck-job listener would fail in the same way.
Existing tenants were not affected because the provisioner only runs
right after the very first migration of a new tenant.

Fix
- TenantInitialUserProvisioner now sets user id 2's username to
  AppUserConstants.SYSTEM_USER_NAME and only replaces its password.
- Admin and interop users are still personalised as
  '<tenant>_admin' and '<tenant>_interop'.
- Javadoc and the startup log line now say the system user keeps its
  name and why.

Resulting users on a new tenant
- <tenant>_admin    (password: initial-user-password or built-in default)
- system            (same password, name unchanged)
- <tenant>_interop  (same password)

Tenants already created with '<tenant>_system' need a one-off fix:
  UPDATE m_appuser SET username = 'system' WHERE id = 2;

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:55:18 +05:30
keshav 99ef5edfd8 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Documentation build / build (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 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 E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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-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 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 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
2026-10-12 10:25:19 +05:30
keshav 9df8e924a1 feat: recurring deposit product rules, lien, auto debit, missed installment penalty, and jobs 2026-10-12 10:25:12 +05:30
niteshandClaude Sonnet 5 1fd94615a9 revert(fixed-deposits): remove FD maturity approval report
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 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 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 E2E Tests / E2E Tests (Shard 9 of 10) (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 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 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
A fixed deposit's maturity is settled directly by the nightly
updateMaturityDetails job again, unconditionally, for every deposit
type and every closure instruction — the approval gate this introduced
turned out to risk more than it protected and is being removed before
it ever shipped enabled by default.

Removes the fdmaturity package entire (report service, orchestrator,
API resource, noon-generation job), its migration and global
configuration, the day-close integration in BranchDayRegisterApiResource,
and FixedDepositAccount#previewMaturityInterestPeriods, which existed
only for this report's preview and has no other caller.

The periodic FD interest posting approval report (0593) is untouched —
a matured deposit's final interest, paid directly by the nightly job,
already surfaces there under "Posted outside approval", same as it did
before this report existed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 12:50:11 +05:30
niteshandClaude Sonnet 5 3d3ce92e93 feat(tenants): personalise seeded user credentials for a brand-new tenant
Adds TenantInitialUserProvisioner: right after the very first Liquibase
migration of a tenant that was never on Flyway, renames the three
seeded users (ids 1-3, otherwise shipping as mifos/system/interopUser
with the public default password) to <tenantIdentifier>_admin/_system/
_interop, with a password from fineract.tenant.initial-user-password
or a built-in default. An existing (Flyway-era) tenant already has its
real users and is left untouched. Wired through TenantDatabaseUpgradeService
and its two test configurations.

Also fixes the two pooled interest-posting services' system-user
lookup, which read username = 'system' — now wrong since that username
is personalised per tenant — to find the seeded system user by its
fixed id (2) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 22:41:25 +05:30
niteshandClaude Sonnet 5 1a914a3de0 feat(fixed-deposits): maturity approval report, scoped to deposits with no closure instruction
Adds the FD maturity approval workflow: a deposit with no closure
instruction of its own (Maturity Instructions left "Not selected")
waits — status, final interest and whatever renewal is later chosen
from the FD Dashboard all run together in one real maturity run, only
once a second user approves the report, as the branch closes its day.

A deposit that already named Transfer to Savings or a Reinvest option
settles itself the moment it matures, unchanged — a decision the
customer already made in writing needs no second approval. The
nightly job is narrowed to skip only the undecided ones;
FixedDepositAccount#previewMaturityInterestPeriods reads the same
maturity-interest calculation the real posting uses, without writing
anything down, so the report's figure is never a guess.

Posting an approved report calls the existing, unchanged
DepositAccountWritePlatformService#updateMaturityDetails per deposit,
each in its own transaction — one with a bad closure target does not
hold up the others, and a retry only touches what did not post last
time.

Adds a reconciliation view (outside-approval) listing deposits that
matured via their own closure instruction, same reasoning as the
interest report's: found by what has no report line, not by naming
the instruction, so a future direct path is caught here too.

Day-close integration mirrors the interest report exactly: blocks on
a pending maturity report, posts approved ones alongside approved
interest reports, independently so one failing does not stop the
other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 19:06:40 +05:30
niteshandClaude Opus 5 6e7eb94776 fix(users): encode password and email it when approving a pending user creation
finalizeCreateUser() (run when a checker approves a CREATE_USER request queued
in m_user_pending_request) saved AppUser.fromJson()'s result straight to
m_appuser via appUserRepository.saveAndFlush() with no encoding step for the
password AppUser.fromJson() auto-generates when the maker ticks "send password
to email" -- AppUser's constructor stores whatever string it is given
verbatim, so that random password was landing in m_appuser.password as plain
text. Only the maker-chosen-password case (request.getPasswordHash() != null)
was ever hashed, because that happens earlier, at submitCreateUser() time.

It also never called the email service at all, in either case. The live
create-user flow (JpaUserDomainService.create()) captures the unencoded
password, encodes it, saves, then emails the welcome message --
finalizeCreateUser() duplicates everything up to the save but stops there, so
a user created through this maker-checker flow never received their
credentials regardless of what sendPasswordToEmail said in the submitted
payload.

Fix: capture the auto-generated plaintext right after AppUser.fromJson(),
encode it with this class's own PasswordEncoder bean (already used the same
way for the maker-chosen-password branch, consistent within the file) before
saving, and call PlatformEmailService.sendToUserAccount() afterwards when
sendPasswordToEmail was true and no maker-chosen password overrides it. A
maker-chosen password is unrecoverable by approval time (submitCreateUser()
redacts it to "[PROTECTED]" immediately), so that case still cannot be
emailed, and isn't -- there is nothing wrong to fix there.

Found by manually submitting a CREATE_USER request locally and reading both
AppUser.fromJson() and the approval path end to end; no existing approved user
was re-created to verify in place, since doing so would need resetting a real
password.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 19:03:17 +05:30
sonali a127710442 feat(loan-products): add product master table and GL account seeding for loan interest rate registe 2026-10-08 17:29:39 +05:30
niteshandClaude Sonnet 5 6fbc3a85a8 feat(fixed-deposits): show the period each posting covers, surface maturity postings, guard against double-posting
Adds interest_period_from to the report line so each posting shows the
span it covers (e.g. "01 Oct - 31 Dec, Quarterly"), not just the date
it ends on, and a postingPeriodLabel on the report itself.

Adds a reconciliation endpoint (outside-approval) that lists fixed
deposit interest posted without this report's approval — chiefly a
deposit that matured before its own posting cycle completed, whose
last period the maturity job settles directly. Read-only, so a branch
can see every rupee of deposit interest it has paid, approved or not.

Guards posting itself against the same situation: before crediting a
line, checks whether its deposit already carries an interest posting
for that exact date (raised by the maturity job in the window between
this report being generated and being posted). If so, the line is
linked to what actually paid it instead of being paid again, and its
amount is reconciled off the report's approved total rather than
failing the whole posting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:06:14 +05:30
niteshandClaude Sonnet 5 949f45c4cd feat(fixed-deposits): FD interest posting approval with pooled GL postings
Adds a maker-checker approval report in front of fixed deposit interest
posting, mirroring the existing savings interest posting report: a
report is generated per branch and product (SINGLE/DOUBLE/SPECIAL), a
second user approves it, and it posts when the branch closes its day.
Day close is blocked until deposit interest due that day is approved.

Ledger entries are pooled per the report's interestPostingDate rather
than raised per deposit: Dr Interest Expense and the Payable/Paid legs
are each raised once for the group's total, and only the final leg
crediting each deposit's own GL is per account — the same pattern the
savings interest posting report already uses, instead of repeating the
full three-stage chain for every deposit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:06:00 +05:30
sonali 919bfb5204 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into feature/advanceLoanProduct 2026-10-07 15:31:57 +05:30
sonali 39e3fcec59 feat: add loan interest backend functionality
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 Messaging Smoke Tests / Smoke Test with ActiveMQ (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 (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-07 15:29:18 +05:30
nitesh 1922455996 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (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 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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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-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
2026-10-07 10:18:27 +05:30
keshav cca4ae583f Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 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 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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (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
2026-10-07 10:14:13 +05:30
keshav 99a7e04872 feat: updates for tds services, rbi reserve config, and cashier api resources 2026-10-07 10:14:04 +05:30
niteshandClaude Opus 5 ac7873a51e feat(shares): add preview-validate endpoint, fix document attachment 500
ClerkShareWithdrawalRequestApiResource: extracted validate(Long)'s checklist
logic into buildShareWithdrawalChecks(Map<?, ?>) and added a new
POST /preview-validate endpoint that runs it against a draft submission (same
fields submit() accepts, same Sundry-downgrade rule applied first) instead of
an already-persisted request. Lets the clerk see the exact checklist a manager
will see on approval before the request is even submitted, with zero risk of
the two checklists drifting apart, since both now call the same method. The
one check validate() runs that a draft cannot -- "Maker and Checker are
different users" -- is reported as a fixed reminder instead of silently
dropped, since the clerk previewing this is necessarily the maker and no
checker has been chosen yet.

DocumentReadPlatformServiceImpl.retrieveDocumentContent(): a document/signature
whose m_document row survives but whose underlying file is missing from the
content store (the usual cause is a database copied between environments
without its file store) was throwing an uncaught ContentStoreException,
surfacing as a raw 500 on every fetch. Now caught and converted to the same
DocumentNotFoundException (404) ImageReadPlatformServiceImpl already uses for
the identical situation on client photos, so the frontend's existing
graceful-fallback handling for a missing attachment applies here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 22:40:12 +05:30
niteshandClaude Opus 5 643427bd1a fix(shares): correct share transaction lookup and validate NONE settlements
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 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 Documentation build / build (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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
redemptionValueOf() read from "m_share_account_transaction" with a
"share_account_id" column. The table is m_share_account_transactions and the
column is account_id, so every share withdrawal approval failed with a
BadSqlGrammarException on the Share Sundry balance guard, before any posting was
attempted. The lookup now also filters on is_active, matching
ShareAdvanceCollectionApiResource.shareValueOf(), so a reversed purchase cannot
price a redemption.

validate() branched on "GL" versus everything else, but approve() handles a
third settlement mode: submit() downgrades a request to NONE when the clerk
names the product's own Share Sundry GL as the destination, and that mode posts
Dr Share Capital / Cr Share Sundry and stops. Validating it as a savings
settlement reported a missing fund source mapping that no posting in this mode
ever reads, so a correct request showed a failed check. NONE is now reported as
not applicable for both the linked-savings and the fund-source checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:03:51 +05:30
niteshandClaude Sonnet 5 6c7c0f8445 build: freeze the default (non-release/maintenance branch) jar version
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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
git-versioning's rev{} fallback previously derived the SNAPSHOT version from
the nearest reachable git tag (major.(minor+1).0-SNAPSHOT), so the built jar's
name drifted as tags moved. Pins it to a fixed 1.15.0-SNAPSHOT instead so the
jar name stays predictable across builds on dev-latest/feature branches.
release/*.*.* and maintenance/*.* branches still version off their own tags,
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:42:12 +05:30
niteshandClaude Sonnet 5 88157ee9d6 Revert "Enforce same-user maker-checker restriction"
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 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 5 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 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 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 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
Reverts commits 2afd4aaf8 and 11a0d502b (both by anshul, same title).
Restores the CHECKER_SUPER_USER self-approval bypass in CommandSourceService
and the six *EditWritePlatformServiceJpaRepositoryImpl classes, and restores
the original isSameMakerCheckerEnabled()/isCheckerSuperUser()-gated same-user
check in PortfolioCommandSourceWritePlatformServiceImpl. Removes the
0588_persist_manually_enabled_maker_checker_permissions.xml migration added
alongside the restriction.

The unconditional same-user block these commits introduced was blocking
legitimate single-admin operation/testing (e.g. creating and then activating
a client as the same CHECKER_SUPER_USER), with no way to resolve it from
inside the app.

11a0d502b's only change (0012_seed_default_platform_api_client.xml comment
wording) is a no-op here: that exact text was independently reworded again by
82336cbe9 after 11a0d502b landed, so there is nothing left of 11a0d502b's
change to revert in that file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:02:34 +05:30
niteshandClaude Sonnet 5 760fff8d3e fix(permissions): persist manually-enabled Teller/Cashier maker-checker settings
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 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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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
Same pattern as 0588_persist_manually_enabled_maker_checker_permissions:
converts a manually-applied Configure Maker Checker Tasks change into a
repeatable migration, this time for the Teller/Cashier module. Found by
diffing can_maker_checker across every permission code common to
fineract_lincpay and a reference environment -- ALLOCATECASHIER_TELLER,
ALLOCATECASHTOCASHIER_TELLER, SETTLECASHFROMCASHIER_TELLER, and
UPDATECASHIERALLOCATION_TELLER were true there but still seeded false here,
confirming they were toggled on manually and never captured for any other
environment (including UAT). The _CHECKER permissions already exist and are
already granted to Clerk/Teller/Branch Manager, so approval works as soon
as this migration runs -- no role-grant changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:29:26 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 b726577031 Update default DB password in application.properties
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 Documentation build / build (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 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 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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:15:03 +05:30
Sonali Likhitkar d1e94fd6a3 Add Fees-Penalty-Interest-Principal repayment transaction processor 2026-10-05 16:13:35 +05:30
keshav d7ff4e4a7b Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 E2E Tests / E2E Tests (Shard 6 of 10) (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 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 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
2026-10-05 12:43:38 +05:30
keshav 7068d65422 feat: teller uniqueness, cashier allocation office check and interest posting report updates 2026-10-05 12:42:44 +05:30
nitesh fd827dec27 fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
2026-10-04 16:02:25 +05:30
nitesh aa264097e7 fix(savings,accounting): fix premature interest calculation date bounds and load interest payable GL mapping on edit
Details of changes:

1. Premature Closure 1-Day Shortfall Fix (FixedDepositAccount & RecurringDepositAccount):
- In calculatePreMatureInterest, explicitly reset transaction end-of-day balances to preMatureDate before invoking calculateInterestPayable.
- Ensure end-of-day balances are restored to current business local date in a finally block.
- Resolves the issue where premature closure missed 1 day of interest (e.g., -17.80 clawback) due to transaction end-of-day balances defaulting to the previous day when premature date matched current business date.

2. Interest Transfer Exclusions (SavingsAccountInterestPostingServiceImpl):
- Exclude interest-transfer withdrawal transactions from non-interest posting transactions and end-of-day cumulative balance updates for fixed deposits transferring interest to linked accounts.

3. Interest Payable GL Load on Edit (AccountingConstants & ProductToGLAccountMappingReadPlatformServiceImpl):
- Added INTEREST_PAYABLE(17) enum constant to CashAccountsForSavings in AccountingConstants.
- Mapped CashAccountsForSavings.INTEREST_PAYABLE to SavingProductAccountingDataParams.INTEREST_PAYABLE in ProductToGLAccountMappingReadPlatformServiceImpl.
- Fixes the bug where Liability -> Interest Payable Account GL was not loading in the product edit form (e.g., /products/fixed-deposit-products/:id/edit) even after being saved.
2026-10-04 11:54:28 +05:30
nitesh 16c67f6701 feat: Fixed Deposit - premature close logic, deposit account closure type, transaction validation, domain service and read service updates 2026-10-04 09:12:17 +05:30
nitesh e846892e9f Merge branch 'feature/bugfix' into dev-latest
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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-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 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 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
Brings in the backend counterpart of "Enforce same-user maker-checker
restriction".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-10-01 13:25:29 +05:30
keshav 56d98bac8d feat(rbi-reserve,savings): add Form1/Form9 returns, interest posting report and branch-day enhancements
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 - PostgreSQL / test (test-core-1) (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-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 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
2026-10-01 13:07:58 +05:30
niteshandClaude Sonnet 5 0d6066e0b6 fix(useradmin): strip password placeholder from a pending-edit replay payload
Fineract E2E Tests / E2E Tests (Shard 3 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 4 of 10) (push) Canceled after 0s
Fineract Documentation build / build (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 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 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
A pending user edit's stored payload keeps a "[PROTECTED]" placeholder for
the password rather than the real value. The actual new password is applied
separately via request.getPasswordHash(), so the placeholder must not reach
AppUser.update() or it tries to re-encode the literal placeholder string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:13:02 +05:30
niteshandClaude Sonnet 5 4499b8afad fix(accounting): post GL entries for a negative (clawback) interest transaction
A premature FD closure that reprices interest downward (e.g. a pre-closure
penalty) posts the difference as a negative interest-posting transaction --
a clawback of interest already recognised in earlier periods. Both the cash-
and accrual-based accounting processors only posted journal entries when the
transaction amount was greater than zero, so a clawback updated the FD
subledger but silently posted nothing to the General Ledger, leaving the two
out of sync.

Treats a negative amount as a mirror-image reversal: positive magnitude,
debit/credit legs swapped -- the same mechanism already used for isReversal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:12:51 +05:30
niteshandClaude Sonnet 5 11a206f42a feat(deposits): RBI premature closure compliance, simplified to always use the contracted rate
Adds a Closure Reason to premature FD closure (Normal, Premature Extension,
Conversion to Another TD, Deceased Settlement, Nominee/Legal-Heir), persisted
as an RBI audit trail on m_deposit_account_term_and_preclosure. RBI-mandated
reasons auto-waive the pre-closure penalty regardless of the maker's own
checkbox; the maker can also waive it explicitly for any other reason.

Interest rate for a premature closure is always the deposit's own contracted
rate -- the same one its regular scheduled postings use throughout its life
-- rather than a slab re-lookup against the actual period held. An earlier
"actual-period vs contracted" toggle was built, tested, then deliberately
removed per explicit request: simpler, and avoids disagreeing with interest
the account was already accruing all along.

Also fixes a real bug found while testing this: a renewed FD term's premature-
closure interest was including the previous term's closing transaction a
second time (it's dated the same day the new term starts), understating the
first day's balance by the whole prior term's payout. Scoped the interest
calculation to transactions strictly after the current term's start date.

Known open issue: a renewed/auto-renewed account can still show a materially
wrong (sometimes negative) premature-closure interest figure in some cases
(seen on account 10, a QUARTERLY-posting auto-renewed FD) -- root cause not
yet confirmed; needs further investigation with debug instrumentation before
this is considered fully correct for every renewal shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:12:40 +05:30
anshul 11a0d502be Enforce same-user maker-checker restriction
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 Documentation build / build (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 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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
2026-10-01 12:03:32 +05:30
anshul 2afd4aaf83 Enforce same-user maker-checker restriction 2026-09-30 17:07:16 +05:30
anshul e5dd6af366 Merge dev-latest into feature/bugfix 2026-09-30 16:47:41 +05:30
niteshandClaude Sonnet 5 ce8bb7e5ca fix(products): named GL account changes (e.g. Savings Interest Payable/Paid) were silently lost on a pending edit
Root cause: ProductPendingEditFieldMapper.populateCoreFields() captured
otherGlMappings with stringValueOfParameterNamed(), which only handles a
JSON string primitive. The maker's edit actually submits otherGlMappings
as a nested {assets,liabilities,expenses,income} object -- the same shape
SavingsProduct#update() already reads correctly via jsonFragment() -- so
the pending-edit row was staged with this field blank every time,
regardless of what the maker changed.

Two consequences, both now fixed:
 - The checker's diff dialog showed "No changes to review" even when a GL
   account mapping (e.g. Savings/Current/FD/RD Interest Payable or Paid)
   was genuinely changed, since the submitted value never made it into
   proposedChanges.
 - Approving the edit replayed a payload with no otherGlMappings at all,
   so the GL account change was silently dropped rather than applied --
   the edit showed as approved, but the product's accounting mapping
   never actually moved.

Fixed the write side (jsonFragment instead of stringValueOfParameterNamed)
and the read-side replay (writeCoreFieldsToJson now re-adds the stored
value as a parsed JSON element via the new putJsonFragment, not a quoted
string, which would have double-encoded it on replay). Shared by Savings,
Current, FD and RD products, since they all go through this one mapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:39:58 +05:30
niteshandClaude Sonnet 5 82336cbe98 fix: remove illegal -- from migration 0012's XML comment
Same mistake as an earlier migration this session: a literal "--" inside
an XML comment is illegal per the XML spec and crashes the entire backend
startup (Liquibase's XML parser rejects the whole changelog, not just this
file). Reworded the prose to avoid it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:00:59 +05:30
Sonali Likhitkar 22c4403a14 feat(loan-product): add advance loan product ext entity, API and liquibase 2026-09-29 17:56:50 +05:30
niteshandClaude Sonnet 5 9436eab129 feat(security): seed a fixed default platform API key via migration instead of a random one at runtime
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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 Documentation build / build (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
Replaces the previous DefaultApiClientBootstrapper (ApplicationRunner
minting a random key on first startup) with a plain Liquibase seed on the
tenant-store database: one fixed, well-known api_key_hash row, inserted
once via migration 0012, same on every deployment and every tenant --
exactly like this fork already seeds default codes/permissions, rather
than generating a secret at runtime.

The trade-off is explicit in the migration's own comment: a fixed key
checked into source is a bootstrap convenience, not a secret, and any
deployment beyond local/dev must rotate it (insert a new hash, deactivate
this row) before going live -- rotation is a direct m_api_client write,
same as the seed itself, since POST /v1/apiclients stays disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 17:01:07 +05:30
niteshandClaude Sonnet 5 1408bddf58 feat(security): auto-mint the first platform-operator API key on deploy, disable minting one over HTTP
A fresh deployment's m_api_client table starts empty, and reaching
#/platform-admin/menu-access requires a valid X-Api-Key -- with no key,
every tenant 401s identically (this is by design, not tenant-specific).

Add DefaultApiClientBootstrapper: an ApplicationRunner that mints exactly
one random key on first startup (guarded by "table still empty", so later
restarts are a no-op) and logs the raw value once to the server's own
application log -- never to the database, never over HTTP.

Disable ApiClientAdminApiResource's POST /v1/apiclients entirely: it only
required ALL_FUNCTIONS, a per-tenant role grant, so any bank's own super
user (or a misconfigured role, as "Clerk" was found to carry it in two
tenants this session) could mint themselves a platform-wide operator key
and bypass the exact ceiling TenantMenuAccessApiResource exists to enforce.
New keys past the first are now a direct m_api_client insert only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 15:36:18 +05:30
anshul e02ab51ee1 Merge branch 'dev-latest' into feature/bugfix
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 4 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 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 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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 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 5 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 - 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
2026-09-29 10:21:21 +05:30
nitesh 3a37a5f90f Merge branch 'feature/bugfix' into dev-latest
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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-5) (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 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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-09-28 18:36:12 +05:30
niteshandClaude Sonnet 5 e7417d16a6 chore: renumber migration 0585 to 0587 ahead of feature/bugfix merge
feature/bugfix independently used 0585/0586 for unrelated migrations
(loan COB catch-up, system module checker permissions), colliding with
this session's own 0585 file. Renumbered to the first free slot after
theirs so both sets of changesets coexist without ambiguity; content and
semantics are unchanged, only the file name and changeset ids moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:35:11 +05:30
niteshandClaude Sonnet 5 24d3bf83bb fix: correct Current-product OD columns, PortfolioProductType mapping, and duplicate Bank Transfer voucher
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (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 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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- Add migration 0585 to add the OD-scheme columns to the correctly-named
  pending-edit shadow table (m_saving_product_temp, singular), fixing
  migration 2033's typo that made it a permanent no-op and broke editing
  any Current product with OD fields set.
- Fix PortfolioProductType.fromInt() to switch on each constant's declared
  value instead of declaration position, which silently mis-mapped ids 3-5
  and NPE'd on id 6 (CURRENT), crashing Journal Entries for Current-account
  GL rows.
- Stop generating a second Bank Transfer voucher at approve time; reuse the
  one already created at raise/submit time instead, eliminating duplicate
  vouchers for the same transfer under two different users' names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:53:25 +05:30
anshul af052dcb4f Merge branch 'dev-latest' into feature/bugfix
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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-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 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 - MariaDB / test (test-core-2) (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 - 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 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
2026-09-28 17:22:25 +05:30
keshav b12268e740 feat: add RBI Form A and Form 9 SLR filing APIs, jobs, and migrations
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
2026-09-28 13:35:32 +05:30
anshul 6ee3bbe67c feat: add command handlers, permissions, and API resources for surveys and loan COB catch-up
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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 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
2026-09-28 12:55:21 +05:30
anshul d8f6c25875 Merge branch 'dev-latest' into feature/bugfix
especially if it merges an updated upstream into a topic branch.
2026-09-28 12:39:03 +05:30
niteshandClaude Sonnet 5 2155c94161 fix(fd): Reinvest Principal Only renews in place, same account, instead of opening a new one
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
Reinvest Principal Only was still on the legacy close-and-reopen path (open
a new FD with just the principal, close the old one) while Reinvest
Principal + Interest and Partial Reinvest had already moved to renewInPlace
-- the same customer choosing a different split ended up with two different
outcomes: same account number, or a brand new one. Unified all three onto
renewInPlace: Principal Only is now exactly Partial Reinvest with the split
fixed at the closing principal, so it renews on the same account with only
the leftover interest actually leaving (to the chosen/linked Savings
account), in both handleFDAccountPayment (Payment dialog / FD Dashboard
Renew) and handleFDAccountMaturityClosure (the nightly job's path for an
account with an already-stored instruction).

Also fixes a related bug found while testing this: recordInPlaceRenewal
re-archived the closing term's full rate chart on every renewal of the same
term, even when it had already been archived by an earlier renewal --
archiveChartSlabs now only runs the first time a term's closing row is
created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:41:18 +05:30
niteshandClaude Sonnet 5 87f3e591ce fix(accounting): tag current-account GL entries entity_type_enum=CURRENT instead of reusing SAVING, everywhere this codebase links an entry to its account
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 Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
m_current_account and m_savings_account each keep their own identity
sequence, so the same numeric id can be a real row in both. Every place
that links a GL journal entry back to its account for display -- so the
Journal Entries screen shows an Account No instead of a blank "-" --
was tagging entity_type_enum=2 (PortfolioProductType.SAVING)
unconditionally, even when the account was actually a current account.
Whenever a current account's id collided with an unrelated savings
account's id, the entry resolved to the wrong one entirely: a customer's
savings deposit could display someone else's current account number, or
vice versa.

Fixed at the write side, in every place found doing this:
- ClerkDisbursementRequestApiResource (Bank Transfer, Collection,
  Disbursement) -- GlEntry now carries an isCurrentAccount flag, decided
  once where the account was already resolved, instead of being
  re-guessed.
- ChequeBookIssuanceChargeService, ClearingJournalService,
  SponsorDDIssueRequestWritePlatformServiceImpl -- same pattern, each
  already knew savings vs current at the point it linked the entry; that
  answer is now threaded through instead of discarded.

Also fixed on the read side:
- JournalEntryReadPlatformServiceImpl: entity_type_enum=6 now resolves
  unambiguously to the current account; entity_type_enum=2 still falls
  back to savings_transaction_id (a reliable signal only a genuine
  savings transaction sets) before guessing, for older rows written
  before this fix.
- JournalEntryReferralServiceImpl (AML referral resolution): the same
  ambiguity could attribute a suspicious-activity alert to the wrong
  client entirely -- a same-numbered stranger's account -- when a
  current account's entity_id collided with a savings account's.
  Resolved with the same entity_type=6 / savings_transaction_id
  precedence as the read-side fix.

Not touched: JournalEntryAggregationJobReader (year-end aggregation
batch job) has the same ambiguity for legacy entity_type_enum=2 rows,
but only ever joins m_savings_account -- it has no current-account
handling at all, standard-Fineract in origin. Extending it is a real
feature addition, not a targeted fix, and touches a scheduled financial
batch job this session cannot rebuild or run to verify; flagged for
separate, deliberate work instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:34:39 +05:30
niteshandClaude Sonnet 5 1e903232f0 fix(fd): pay late-renewal gap interest to the linked Savings account, not the FD
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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 (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Gap-period interest is earned at the linked account's rate, not the FD's --
folding it onto the FD's own balance meant "Re-Invest Maturity Amount"
silently carried savings interest into the new FD term as if it were FD
interest, and "Re-Invest Principal Only" paid it out through the FD's own
closure math instead of it already being where it belongs. Now posted
directly onto the linked account as its own INTEREST_POSTING transaction;
the FD's balance is left untouched, so every closure type renews/transfers
its real maturity amount. Renewal Preview updated to match (projectedBalance
is now just the FD's own balance) and now also returns the linked account's
own account number, so staff can see exactly where the gap interest is
about to land before confirming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:11:50 +05:30
niteshandClaude Sonnet 5 2c49d6f637 feat(fd): add read-only renewal preview endpoint, fix stale linked-account reads
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 - 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract 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
GET /fixeddepositaccounts/{id}/renewal-preview -- shown in the FD
Dashboard's Payment dialog before staff confirm a late renewal, so they see
the actual numbers (linked account's rate, gap days, gap-interest amount,
projected new balance) instead of confirming blind. Shares its calculation
with the posting path (creditGapInterest) via a new computeGapInterestAmount
helper, so the previewed number is exactly what gets credited.

Also fixes fdAccount.getLinkedAccountId() returning null for an account
confirmed to have linked_account_id set in the database -- every caller
that needs the FD's Deposit Account Link now reads it fresh via SQL
(resolveLinkedAccountId) instead of that stale JPA entity accessor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:55 +05:30
niteshandClaude Sonnet 5 331c553164 fix(accounting): interest postings were never reaching the general ledger
createInterestPostingJournalEntriesForSavings() documents a 3-stage
posting (Dr Interest On Savings -> Cr Payable, Dr Payable -> Cr Paid, Dr
Paid -> Cr Savings Control), but the first stage was never actually coded
-- the interest expense account was never even fetched. For a product with
no distinct Interest Payable/Paid accounts configured (every product in
this system, checked directly), the remaining two stages then collapsed
into a same-account no-op that correctly refuses to post, leaving interest
silently added to the account's balance with zero GL entries. Affects every
deposit type equally (Savings, FD, RD), not just FD -- confirmed by
checking every product's accounting mappings. Added the missing stage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:46 +05:30
niteshandClaude Sonnet 5 3fb008a22a fix(products): clearer error message for an FD product amount field that's too large
The FD product's exception mapper fell back to a bare "Unknown data
integrity issue with savings product." for any DB error it didn't
recognize, with no detail -- unlike its sibling class for regular Savings
products, which at least appends the underlying error. Added a specific,
actionable message for the numeric-overflow case (an amount field entered
too large for its column), and made the generic fallback include the real
detail too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:28 +05:30
niteshandClaude Sonnet 5 8b2fb052d4 fix(fd): keep a genuinely-matured FD on the Dashboard instead of dropping it
The maturity-dashboard query only ever selected ACTIVE(300) accounts. That
was fine while nothing ever really reached MATURED(800) in this dev setup
(test dates were only SQL-backdated, never run through the real nightly
job), but the first time the job actually flipped an account's real status,
it silently vanished from the ladder instead of showing under "Matured" --
exactly the state the ladder exists to surface. Now selects ACTIVE or
MATURED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:40:21 +05:30
anshul cebba87864 Merge branch 'dev-latest' into feature/bugfix
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 (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 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 Documentation build / build (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
2026-09-25 11:24:24 +05:30
niteshandClaude Sonnet 5 ea39c8e82a fix(current-account): recognize Withdrawn and Rejected as terminal when checking for a duplicate product application
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 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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
countActiveByClientAndProduct only excluded status 600 (CLOSED), so a
client whose prior application for the same product was Withdrawn (400)
or Rejected (500) -- both terminal, per CurrentAccount.withdraw()/
reject() -- was still blocked from applying again, same as if that
account were genuinely still open. Only 100/200/300 (submitted,
approved, active) should collide with a fresh application.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:22:31 +05:30
niteshandClaude Sonnet 5 32411c924b fix(accounting): stop the Journal Entry view showing a colliding current account's number for a savings GL entry
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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 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 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
entity_type_enum=2 (PortfolioProductType.SAVING) is written for both
savings and current-account journal entries -- current products
deliberately share SAVING's GL type space -- so it can't distinguish
them, and m_savings_account/m_current_account each have their own id
sequence, meaning an entity_id can be a real row in both. The account-
number column's COALESCE(ca.account_no, sa.account_no) always preferred
the current account's number whenever both matched, so a savings
deposit's own journal entry displayed an unrelated current account's
number instead of the account it actually posted against.

savings_transaction_id is the one reliable signal available on this
query: only a genuine savings transaction sets it. Checking it first
resolves the correct account whenever it's present, and falls back to
the previous behavior only when it's absent (current-account entries,
which don't set it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 10:58:44 +05:30
niteshandClaude Sonnet 5 f696499102 fix(current-account): stop account-id collisions between savings and current accounts from misrouting money and losing GL entries
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 - 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
m_current_account and m_savings_account each keep their own identity
sequence, so the same numeric id can be a real row in both -- an id-only
lookup used to decide "is this a current account?" once sent a real
deposit into an unrelated client's current account instead of the
savings account a request actually named, across every entry point that
did this: cashier deposit approval/reprocess, clerk withdrawal approval,
and clerk disbursement collection/disbursement/bank-transfer. All of
those now also verify the account_no before trusting an id match.

Separately, GL posting for current-account transactions has been
silently failing since acc_product_mapping had two rows for the same
(product_id, product_type, financial_account_type) slot -- current
products share PortfolioProductType.SAVING's id space with real savings
products, and a NULL payment_type doesn't collide under that column's
own unique constraint, so duplicates went unnoticed. The single-result
repository lookup that read this mapping threw on the duplicate, which
was swallowed by a try/catch, so account balances kept updating while no
journal entry was ever posted. Both current-account GL lookups now take
the most recently created mapping instead of failing outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:14:32 +05:30
anshul 7eabaf2542 Merge branch 'dev-latest' into feature/bugfix
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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
2026-09-24 18:09:48 +05:30
keshav 4b9093007f feat: update TDS config, RBI reserve, current products, OSS returns, and dormancy services
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 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 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
2026-09-24 18:06:29 +05:30
anshul 9beae3df28 Implement organization maker-checker workflows
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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 Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (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 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
2026-09-24 17:22:39 +05:30
niteshandClaude Sonnet 5 d2e13cc7b9 feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only
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 Documentation build / build (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 - 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 - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own
via the nightly job, exactly as before -- the grace period never delays that. An FD opened with
NO instruction at all (the new default -- see the frontend's account-creation changes) is now
never touched by the job; it just sits matured until staff renew it manually from the FD
Dashboard, backdated to the original maturity date within the product's grace window, or from
today with gap-period interest (at the linked account's rate) credited first once it's passed.

New endpoints:
- POST /fixeddepositaccounts/{id}/renew -- the Dashboard's own quick Reinvest choice
  (Principal+Interest / Principal Only), for the two-option case.
- POST /fixeddepositaccounts/{id}/credit-gap-interest -- a pre-step the frontend's Payment
  dialog now calls before submitting the existing generic `payment` command, so the Dashboard's
  Renew action offers every closure type Payment does (Transfer to Savings, both Reinvest
  options, Partial Reinvest), not just two, while still crediting gap-period interest first.

Also fixes a real, pre-existing bug found while building this: REINVEST_PRINCIPAL_ONLY never
actually paid its leftover interest anywhere -- handleFDAccountMaturityClosure withdrew only the
principal into the new deposit and never referenced its own toSavingsId parameter, silently
stranding the interest on an account it also never closed. Now transfers the remainder to the
target savings account and actually closes the old account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:55:25 +05:30
niteshandClaude Sonnet 5 ecfe8aa76a feat(fd): add Renewal Grace Period product field, both storage paths
New per-FD-product "Renewal Grace Period (Days)" term field: null/0 (every existing product)
keeps today's instant, same-night auto-renewal; a positive value is later read by the nightly
maturity job and the manual Renew action to decide whether/how a matured FD waits for staff to
act instead of renewing itself.

Wired into the live product path end-to-end -- DepositsApiConstants param, DepositTermDetail
column + diffing, DepositProductAssembler (create and update), DepositProductReadPlatformServiceImpl,
FixedDepositProductData -- migration 0572 adds the column.

Also wired into the maker-checker pending-edit mirror (m_saving_product_temp /
FixedDepositEditFields / ProductPendingEditFieldMapper), a second, independent storage path a
governed product edit uses -- without this the field silently dropped out of a checker's
"Proposed changes" review even though it was submitted. Migration 0573 adds its column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:55:11 +05:30
niteshandClaude Sonnet 5 a7cb09f15c feat(fd): add lean FD maturity dashboard read endpoint
New GET /v1/fixeddepositaccounts/maturity-dashboard: a narrow, purpose-built query for the FD
Dashboard's maturity ladder (account/client/product identity, maturity date, deposit/maturity
amounts) instead of routing through the general FixedDepositAccountsApiResource#retrieveAll,
whose mapper joins six extra tables for audit/currency/tax fields this screen never shows.

Adds the index migration (0571) the dashboard's WHERE clause needs -- neither
m_savings_account.deposit_type_enum/status_enum nor
m_deposit_account_term_and_preclosure.maturity_date had one before, so the endpoint would
otherwise run a full table scan on top of being wide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:54:57 +05:30
niteshandClaude Sonnet 5 26d4c241c7 fix(clerk-disbursement): validate savings withdrawal eligibility when a request is raised
A clerk raising a disbursement request with a Dr leg against a savings account had no way to
know the withdrawal would be refused (inactive account, still locked-in, or insufficient
balance against its own minimum) until a manager tried to approve it and the whole request
bounced back. Checks the same conditions SavingsAccount.withdraw() enforces at entry time
instead, against the same columns, so the clerk sees the real reason immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:54:03 +05:30
niteshandClaude Sonnet 5 dbc2737ada fix(deposits): FD interest posting silently missed its GL entry on single-period maturity
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract 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 Documentation build / build (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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
SYMPTOM
An FD account whose entire term fits inside one interest-posting
period (e.g. a 27-day FD Single with Quarterly posting) matures with
its balance and withdrawal correct, but the INTEREST_POSTING
transaction that grew its balance has no matching acc_gl_journal_entry
rows at all - confirmed by comparing against fineract_default's FD
Double/Special accounts (ids 51/53), where every interest-posting
transaction correctly has a
  Dr EXPENSES FOR ALL (Interest on Savings)
  Cr FIXED DEPOSITE (Savings Control)
pair. Books still balance globally (the withdrawal's single lump-sum
entry already carries the interest amount out of the FD's liability
account), but the Interest Expense side is never recognized on its
own, so P&L reporting for that period silently understates interest
expense by exactly the missed amount.

ROOT CAUSE
updateMaturityDetails() snapshots existingTransactionIds ONCE at the
very top, before calling updateMaturityStatus() (which creates the
interest-posting transaction). The old comment here said this was
deliberate - "interest postings ... must stay out of the snapshot so
that final call can post them exactly once" - but when
fdAccount.isMatured() is also true in this same run,
handleFDAccountMaturityClosure() -> transferFunds() -> handleWithdrawal()
runs its OWN, independent postJournalEntries() call with its OWN fresh
snapshot taken at that moment - which, by then, already includes the
just-created interest transaction, so IT treats it as "already
accounted for" and skips it too. Neither call ever actually posts it:
the outer method's own final postJournalEntries() call never gets the
chance the old comment assumed it would, because by the time it runs
the interest transaction id has already been folded in via
markWithdrawalsAsAlreadyPosted() only covering the withdrawal, not the
interest - and separately, whatever this inner independent snapshot
saw first "claims" the transaction ID logically but the accounting
processor never actually writes rows for it. Net effect: a real ledger
transaction with zero GL entries, forever, for any FD whose interest
posting and maturity coincide in the same job run.

Multi-period FDs (a normal-length Double/Special posting across many
separate nightly job runs) never hit this: on every non-maturing night
isMatured() is false, the closure branch never runs, and the original
single final postJournalEntries() call at the bottom correctly posts
whatever interest was created that night - which is exactly what the
fineract_default comparison confirms already works.

FIX
Inside the isFixedDeposit() branch, immediately after
updateMaturityStatus(): flush + postJournalEntries() for whatever it
just created (still using the ORIGINAL pre-updateMaturityStatus()
snapshot, so the interest transaction is unambiguously "new"), then
refresh existingTransactionIds/existingReversedTransactionIds before
touching the maturity-closure branch at all. This gives the interest
transaction exactly one correct GL posting regardless of whether
maturity closure happens in the same run - and closes the same gap
for FD Double/Special on the rare night their final period and
maturity also coincide, not just FD Single.

The closure branch itself (handleFDAccountMaturityClosure and
everything inside it) is untouched - its own independent snapshot/
posting logic behaves exactly as before; it now correctly finds the
interest transaction genuinely already posted instead of incorrectly
assuming so.

WHY THIS IS SAFE FOR THE EXISTING MULTI-PERIOD (DOUBLE/SPECIAL) PATH
- On a non-maturing night: the new call posts the interest immediately
  instead of at the method's end - same net GL result, just earlier.
  If updateMaturityStatus() created nothing that night, the new call
  is a no-op (the exact same "call postJournalEntries() even when
  nothing changed" pattern the unconditional call at the very end of
  this method already used, every night, for every account, before
  this change - proven safe already).
- The RecurringDepositAccount (else-if) branch is completely untouched.

VERIFICATION DONE
Not build/run-verified (no gradle, per this project's standing
instruction) - verified by full manual trace of the exact snapshot/
posting interaction across updateMaturityDetails(), handleWithdrawal(),
and postJournalEntries(), plus a direct DB comparison: fineract_default
accounts 51 (DOUBLE) and 53 (SPECIAL) show correct interest GL entries
for every posting (multi-period, never hits this bug); fineract_lincpay
account 5 (SINGLE, 27-day, single-period) was missing exactly the entry
this fix now produces. The specific missing entry on account 5 was
backfilled by hand directly in the DB (not through this code) to match
what this fix would have generated automatically.

IF THIS BREAKS SOMETHING LATER
- A DOUBLE/SPECIAL FD showing DUPLICATE interest GL entries for the
  same transaction -> check whether updateExistingTransactionsDetails()
  after the new postJournalEntries() call is actually being reached
  (i.e. no exception thrown by that call swallowing the refresh).
- An FD's interest-posting transaction still missing its GL entry ->
  query acc_gl_journal_entry WHERE savings_transaction_id = <the
  INTEREST_POSTING txn id> and confirm 0 rows; if so, this fix (or its
  ordering relative to a future change here) has regressed.
- Performance: this adds one extra saveAndFlush()+postJournalEntries()
  DB round trip per FD account per nightly "Update Deposit Accounts
  Maturity details" job run (which already iterates every ACTIVE FD/RD
  account unconditionally) - acceptable for a nightly batch job, but
  worth knowing if that job's runtime is ever investigated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:40 +05:30
niteshandClaude Sonnet 5 f457b9b8c7 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD/RD account with cash and the money moved
in instantly, with no Manager approval step - unlike a Savings/Current
deposit from the same screen, which correctly creates a PENDING
m_cashier_deposit_request. Discovered by tracing why a real FD
funding deposit never showed up in Branch Ops -> Approvals: it had
posted directly, no request record ever existed for it.

ROOT CAUSE (two layers, both fixed here / in the paired frontend commit)
1. Frontend gate (transaction-entry.component.ts confirmSubmit(), see
   frontend commit): the maker-checker branch for a cashier deposit
   only matched row.acTyp in ('SD','CA','CB','GL') - 'FD' and 'RD' were
   never in that list, so a deposit into either fell through every
   other branch to the generic, unguarded direct POST at the bottom of
   confirmSubmit().
2. Even with the frontend gated correctly, approve() here only knew
   how to tell Current apart from "everything else -> plain savings
   deposit command" (isCurrentAccountById()). FD/RD accounts share the
   m_savings_account table with plain Savings (distinguished only by
   deposit_type_enum 200/300 vs 100), so an FD/RD deposit request would
   have been posted through the generic savingsAccountDeposit command,
   which does not accept a term-deposit account id.

FIX
- resolveDepositAccountCategory(accountId): looks up the real category
  fresh from the DB (m_current_account / m_savings_account.
  deposit_type_enum) instead of trusting the request's own account_type
  string - same defensive pattern this class already used for Current.
- New executeFixedDepositDeposit() / executeRecurringDepositDeposit():
  post through Fineract's actual FD/RD deposit commands
  (CommandWrapperBuilder#fixedDepositAccountDeposit /
  #recurringAccountDeposit, hitting /fixeddepositaccounts/{id}/
  transactions and /recurringdepositaccounts/{id}/transactions) instead
  of the generic savings command.
- approve() now branches on all four categories (Current / Fixed
  Deposit / Recurring Deposit / Savings) instead of two.

RELATED DATA CLEANUP DONE THIS SESSION (not in this commit)
The one FD deposit made before this fix existed left an orphaned
m_savings_txn_denomination row (physically "counted" cash with no
approved request backing it), which inflated the Branch Day Register's
"Vault + Cashiers" total above the approved-requests-only "book" total
by exactly that amount every day going forward. That artifact row was
deleted directly (not backfilled with a fabricated approval - the
Claude Code safety layer correctly refused that approach as audit-log
tampering). No code in this repo does that cleanup automatically.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again -> check the
  frontend gate list in confirmSubmit() (paired commit) still includes
  'FD'/'RD'.
- approve() throwing on an FD/RD deposit request -> check
  resolveDepositAccountCategory() is returning FIXED_DEPOSIT/
  RECURRING_DEPOSIT (query m_savings_account.deposit_type_enum for the
  account id in question: 200 = FD, 300 = RD).
- Branch Day Register showing a mysterious Vault-vs-book Excess again
  -> check for an m_savings_txn_denomination row with no matching
  m_cashier_deposit_request; that combination should be impossible now
  that this fix is in place, so a new one appearing means this fix (or
  its frontend half) regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:13 +05:30
niteshandClaude Sonnet 5 23ee4f1063 fix(deposits): Deposit Account Link wrongly demanded a co-op share account
SYMPTOM
Creating an FD/RD account with "Deposit Account Link" enabled and a
Savings/Current account correctly picked in the UI still failed with:
  "A share account must be chosen when Share Linking is enabled."
even though no share account field exists anywhere on the FD/RD form.

ROOT CAUSE
SavingsAccountAssembler.applyCbsFields() has one boolean flag,
shareLinkingEnabled, serving two unrelated features that got
collided together (traceable to commit dc0539d53, which repurposed
the flag for co-op Share Account linking on plain Savings products):
  - On a plain Savings product: "Share Linking" = link a co-op
    m_share_account (shareAccountId).
  - On an FD/RD product: the SAME flag is literally labeled "Deposit
    Account Link" on the product screen
    (fixed-deposit-product-details-step.component.html) and is meant
    to require the pre-existing, native linkedAccountId field (already
    wired end-to-end: SavingsAccountAssembler sets it a few lines below
    this check, the FD "Maturity Instructions: Transfer to Savings"
    flow already depends on it, DepositAccountReadPlatformServiceImpl
    already reads it back for display).
  Before dc0539d53, 7 real historical FD accounts (ids 51-57 in
  fineract_default) prove linkedAccountId was the correct, working
  mechanism for this. The block added by that commit made shareAccountId
  mandatory unconditionally, for every product, breaking FD/RD.

FIX
SavingsAccountAssembler.applyCbsFields(): when shareLinkingEnabled is
true, branch on account.depositAccountType():
  - FIXED_DEPOSIT / RECURRING_DEPOSIT: require linkedAccountId (the
    field the FD/RD form already sends) instead of shareAccountId.
  - everything else (plain Savings): unchanged, still requires and
    validates shareAccountId against m_share_account.

SCOPE
Single method, single file. No new column, no new flag, no frontend
change - the frontend's Deposit Account Link picker was already
correct; only this one validation block was reading the wrong field
for FD/RD.

IF THIS BREAKS SOMETHING LATER
- "A share account must be chosen..." reappearing for FD/RD -> check
  account.depositAccountType() is resolving to FIXED_DEPOSIT/
  RECURRING_DEPOSIT at this point (it's a simple override per account
  subclass, see FixedDepositAccount#depositAccountType()).
- A plain Savings product's genuine co-op Share Linking breaking ->
  that path (the `else` branch here) is untouched; if it breaks, the
  regression is elsewhere.
- A much larger version of this fix (a whole new
  depositAccountLinkRequired flag decoupled from shareLinkingEnabled,
  ~18 files) was attempted earlier in the same session and fully
  reverted at the user's request after repeated Gradle build-cache
  issues made it impossible to verify was even running; this is the
  intentionally minimal replacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:55 +05:30
niteshandClaude Sonnet 5 bc38216ca3 fix(deposits): FD Single Rate Calculator crashed for tenures under 30 days
SYMPTOM
Opening the FD account create screen for a "SINGLE" (Days < 1 Year)
product, entering a Deposit Tenure under 30 days (e.g. 27), and
clicking "Find Rate" returned a 400:
  "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
The Rate Calculator preview always converted the entered tenure to
months before calling GET /fixeddepositaccounts/calculate-fd-interest
(fixed-deposits.service.ts -> fixed-deposit-account-interest-rate-
chart-step.component.ts, calculateRate()):
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, and
DepositAccountDataValidator.validateForInterestCalc() requires
tenureInMonths > 0 unconditionally, so the request was rejected
before it ever reached the calculation service.

FIX
Added `tenureInDays` as an alternative to `tenureInMonths` for this
one calculator endpoint only (DepositsApiConstants.
FIXED_DEPOSIT_ACCOUNT_INTEREST_CALCULATION_PARAMETERS) - not a
general-purpose FD field, not touched anywhere else:
  - DepositAccountDataValidator: tenureInMonths is required unless
    tenureInDays is a positive value (either satisfies the check).
  - FixedDepositAccountsApiResource#calculateFixedDepositInterest:
    accepts the optional tenureInDays query param.
  - FixedDepositAccountInterestCalculationServiceImpl#calculateInterest:
    when tenureInDays > 0, the exact-calendar maturityDate is computed
    via submittedOnDate.plusDays(tenureInDays) instead of
    plusMonths(tenureInMonths) (which would silently compute a 0-day
    maturity window for a sub-month tenure).
  - Frontend (separate commit) now sends tenureInDays instead of a
    truncated-to-zero tenureInMonths whenever the entered tenure is
    under 30 days.

SCOPE / WHAT THIS DOES NOT TOUCH
Only the interest-preview calculator endpoint above. Real FD/RD
account submission still uses depositPeriod + depositPeriodFrequencyId
(Days=0) exactly as before; this commit does not change how an actual
account's maturity date/amount gets computed at activation.

IF THIS BREAKS SOMETHING LATER
- "tenureInMonths must be greater than 0" reappearing for a SHORT FD
  tenure -> check the frontend is actually sending tenureInDays (network
  tab on the Find Rate call); the validator's OR-logic is in
  DepositAccountDataValidator.validateForInterestCalc().
- A wrong preview maturity amount for a sub-month FD Single -> check
  FixedDepositAccountInterestCalculationServiceImpl#calculateInterest's
  branch choosing plusDays vs plusMonths for maturityDate.
- This bug and fix were previously implemented once, then fully
  reverted mid-session at the user's request (unrelated Deposit Account
  Link regression got tangled up with it), then reapplied cleanly here
  as a standalone, narrowly-scoped fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:38 +05:30
334 changed files with 33751 additions and 5644 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ gitVersioning.apply {
}
}
rev {
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
version = '1.15.0-SNAPSHOT'
}
}
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return accountMappingDetails;
}
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
@Override
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
PortfolioProductType.CURRENT.getValue());
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
return setCashSavingsProductToGLAccountMaps(mappings);
}
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
}
return null;
}
@Override
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
@@ -463,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
// Income
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
// Interest Payable is editable on cash too, and optional. Interest posting routes
// through it on either basis, so a product can name one to carry interest declared
// but not yet credited; a product that names none still posts straight to Savings
// Control. Created-or-merged because a product configured before this row existed
// has no mapping to merge into.
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
Collection<Teller> findTellerByOfficeId(Long officeId);
long countByDeletedFalse();
long countByOfficeIdAndDeletedFalse(Long officeId);
}
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
public boolean existsAny() {
return this.repository.countByDeletedFalse() > 0;
}
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
public boolean existsInOffice(final Long officeId) {
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
}
}
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
public TellerAlreadyExistsException() {
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
}
public TellerAlreadyExistsException(final String officeName) {
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
officeName);
}
}
@@ -270,6 +270,7 @@ public final class AccountingConstants {
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14), //
INTEREST_PAYABLE(17), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
@@ -157,12 +157,16 @@ public class CommandSourceService {
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_BUCKET".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_RANGE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())
|| isDdsEntity(commandSource.getEntityName())) {
@@ -917,6 +917,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteFund(final Long fundId) {
this.actionName = "DELETE";
this.entityName = "FUND";
this.entityId = fundId;
this.href = "/funds/" + fundId;
return this;
}
public CommandWrapperBuilder createReport() {
this.actionName = "CREATE";
this.entityName = "REPORT";
@@ -1045,6 +1053,70 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createPaymentType() {
this.actionName = "CREATE";
this.entityName = "PAYMENTTYPE";
this.entityId = null;
this.href = "/paymenttypes/template";
return this;
}
public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) {
this.actionName = "UPDATE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) {
this.actionName = "DELETE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder createSponsorBank() {
this.actionName = "CREATE";
this.entityName = "SPONSORBANK";
this.entityId = null;
this.href = "/sponsorbanks";
return this;
}
public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) {
this.actionName = "UPDATE";
this.entityName = "SPONSORBANK";
this.entityId = sponsorBankId;
this.href = "/sponsorbanks/" + sponsorBankId;
return this;
}
public CommandWrapperBuilder updateSponsorDdGlMapping() {
this.actionName = "UPDATE";
this.entityName = "SPONSORDDGLMAPPING";
this.entityId = null;
this.href = "/sponsordd/gl-mapping";
return this;
}
public CommandWrapperBuilder updateInstitutionProfile() {
this.actionName = "UPDATE";
this.entityName = "INSTITUTIONPROFILE";
this.entityId = null;
this.href = "/institutionprofile";
return this;
}
public CommandWrapperBuilder updateBranchProfile(final Long officeId) {
this.actionName = "UPDATE";
this.entityName = "BRANCHPROFILE";
this.entityId = officeId;
this.href = "/branchprofile/" + officeId;
return this;
}
public CommandWrapperBuilder createLoanProduct() {
this.actionName = "CREATE";
this.entityName = "LOANPRODUCT";
@@ -2388,6 +2460,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createLoanBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "LOANBASEINTERESTRATE";
this.entityId = null;
this.href = "/loanproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -3246,6 +3326,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder extendTenureRecurringDepositAccount(final Long accountId) {
this.actionName = "EXTENDTENURE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/recurringdepositaccounts/" + accountId + "?command=extendTenure";
return this;
}
public CommandWrapperBuilder prematureCloseRecurringDepositAccount(final Long accountId) {
this.actionName = "PREMATURECLOSE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
@@ -3551,6 +3640,13 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateCurrency() {
this.actionName = "UPDATE";
this.entityName = "CURRENCY";
this.href = "/currencies";
return this;
}
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
this.actionName = "UPDATE";
this.entityName = "EXTERNALSERVICES";
@@ -4891,4 +4987,36 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/originators/" + originatorId;
return this;
}
public CommandWrapperBuilder createSurvey() {
this.actionName = "CREATE";
this.entityName = "SURVEY";
this.entityId = null;
this.href = "/surveys";
return this;
}
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
this.actionName = "UPDATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
this.actionName = "ACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
this.actionName = "DEACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
}
@@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants {
public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days";
public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting";
public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end";
public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval";
public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month";
public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group";
public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group";
@@ -69,6 +69,12 @@ public interface ConfigurationDomainService {
boolean isSavingsInterestPostingAtCurrentPeriodEnd();
/**
* Whether fixed deposit interest is reported and approved before it is posted, instead of being
* posted directly by the nightly job.
*/
boolean isFixedDepositInterestPostingApprovalEnabled();
Integer retrieveFinancialYearBeginningMonth();
Integer retrieveMinAllowedClientsInGroup();
@@ -505,6 +505,7 @@ public class FineractProperties {
private FineractTransactionProcessorItemProperties heavensFamily;
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
private FineractTransactionProcessorItemProperties rbiIndia;
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
@@ -43,6 +43,8 @@ public enum JobName {
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -74,6 +76,9 @@ public enum JobName {
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
POST_DDS_INTEREST("Post DDS Interest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
; //
private final String name;
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
import org.springframework.stereotype.Component;
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
public class CurrenciesApiResource {
private final OrganisationCurrencyReadPlatformService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
final var command = new CurrencyUpdateCommand();
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency()
.withJson(toApiJsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<CurrencyUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.monetary.handler;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CURRENCY", action = "UPDATE")
public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler {
private final CurrencyWritePlatformService writePlatformService;
@Autowired
public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies");
final List<String> currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes);
final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build();
final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(Map.of("currencies", response.getCurrencies())) //
.build();
}
}
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
SAVING(2, "productType.saving"), //
CLIENT(5, "productType.client"), //
PROVISIONING(3, "productType.provisioning"), //
SHARES(4, "productType.shares"); //
SHARES(4, "productType.shares"), //
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
// product and a current product with the same id read each other's GL rows.
CURRENT(6, "productType.current"); //
private final Integer value;
private final String code;
@@ -52,17 +55,25 @@ public enum PortfolioProductType {
return null;
}
// Matched against each constant's own declared value above, not its declaration position --
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
// (entity_type_enum=6) reached this on the Journal Entries screen.
switch (v) {
case 1:
return LOAN;
case 2:
return SAVING;
case 3:
return CLIENT;
case 4:
return PROVISIONING;
case 5:
case 4:
return SHARES;
case 5:
return CLIENT;
case 6:
return CURRENT;
default:
return null;
}
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.springframework.stereotype.Component;
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
public class PaymentTypeApiResource {
private final PaymentTypeReadService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
@GET
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
final var command = new PaymentTypeCreateCommand();
public String createPaymentType(final PaymentTypeCreateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType()
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeCreateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@PUT
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
final PaymentTypeUpdateRequest request) {
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
request.setId(paymentTypeId);
final var command = new PaymentTypeUpdateCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId)
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeUpdateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@DELETE
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
final var command = new PaymentTypeDeleteCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build();
command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build());
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
}
@@ -18,33 +18,50 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
return paymentTypeWriteService.createPaymentType(command.getPayload());
@Autowired
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder()
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,41 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
return paymentTypeWriteService.deletePaymentType(command.getPayload());
@Autowired
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build();
final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,51 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
return paymentTypeWriteService.updatePaymentType(command.getPayload());
@Autowired
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder()
.id(command.entityId())
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
private final Integer value;
private final String code;
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
case 600 -> TRANSFER_TO_GL;
default -> INVALID;
};
}
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
return this.equals(PARTIAL_REINVEST);
}
/**
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
* Only the fixed-deposit premature-closure handler implements it -- see
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
* terminal "pay the balance out in cash" branch.
*/
public boolean isTransferToGl() {
return this.equals(TRANSFER_TO_GL);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount";
@@ -208,7 +212,18 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
// credited straight to this GL instead of a savings account or cash.
public static final String toGlAccountIdParamName = "toGlAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
// per-request flag rather than a stored account field.
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
// penalty and why.
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -237,7 +252,7 @@ public final class DepositsApiConstants {
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
SavingsApiConstants.taxGroupIdParamName));
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
@@ -247,11 +262,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -373,6 +390,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
fixedDepositRequestData.add("feeToIncomeAccountMappings");
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
fixedDepositRequestData.add("otherGlMappingsMap");
return fixedDepositRequestData;
}
@@ -413,6 +432,10 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("withdrawalFeeForTransfers");
recurringDepositRequestData.add("enableLockinPeriod");
recurringDepositRequestData.add("maturityInstructions");
@@ -472,6 +495,10 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("maturityInstructions");
recurringDepositRequestData.add("creditMaturityProceeds");
recurringDepositRequestData.add("penaltyGracePeriodMissedInstallments");
@@ -484,6 +511,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
recurringDepositRequestData.add("feeToIncomeAccountMappings");
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
recurringDepositRequestData.add("otherGlMappingsMap");
return recurringDepositRequestData;
}
@@ -511,6 +540,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
@@ -626,6 +656,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
// Lien Marked: how much of this deposit may be given as loan, capped by its product's percentage.
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add(isSeniorCitizenParamName);
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
return recurringDepositRequestData;
@@ -0,0 +1,100 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import java.util.Locale;
/**
* The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}.
*
* <p>
* The three schemes differ in where a posting of interest ends up, which is what the interest posting
* report has to show and the posting itself has to honour:
*
* <ul>
* <li>{@link #SINGLE} — short deposits, priced from a slab table in days. Interest stays in the
* deposit and is paid with the principal at maturity.</li>
* <li>{@link #DOUBLE} — deposits of a year or more, priced from a slab table in years. Interest is
* compounded back into the deposit, so the maturity value is principal plus interest.</li>
* <li>{@link #SPECIAL} — the monthly income scheme. Interest is posted monthly and swept out to the
* customer's linked savings account, which is why a special deposit cannot be opened without one;
* the deposit itself matures at its principal, the interest having already been paid away.</li>
* </ul>
*
* <p>
* The database column is a string rather than an enum ordinal, so the names here are the stored
* values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks
* because the column carries a default of SINGLE and older products predate the column entirely.
*/
public enum FixedDepositType {
/** Interest is held in the deposit and paid at maturity. The default for a product that names none. */
SINGLE,
/** Interest is compounded into the deposit; the maturity value includes it. */
DOUBLE,
/** Interest is posted monthly and transferred to the linked savings account. */
SPECIAL;
/**
* The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}.
*
* <p>
* A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a
* product carrying something else is a misconfiguration that must not stop its customers' interest
* from being reported.
*/
public static FixedDepositType fromString(final String fdType) {
if (fdType == null || fdType.isBlank()) {
return SINGLE;
}
for (final FixedDepositType candidate : values()) {
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
return candidate;
}
}
return SINGLE;
}
public static boolean isValid(final String fdType) {
if (fdType == null || fdType.isBlank()) {
return false;
}
for (final FixedDepositType candidate : values()) {
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
return true;
}
}
return false;
}
/** Whether interest leaves the deposit for the linked savings account each time it is posted. */
public boolean isPaidOutMonthly() {
return this == SPECIAL;
}
/** Whether a posting of interest adds to the deposit's own balance. */
public boolean isRetainedInDeposit() {
return this != SPECIAL;
}
/** What a customer sees: "FD Special", "FD Double", "FD Single". */
public String displayName() {
return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH);
}
}
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import lombok.Getter;
/**
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
* server regardless of what a client happened to also send for the plain waive-penalty flag.
*/
@Getter
public enum PrematureClosureReasonType {
INVALID(0, "prematureClosureReasonType.invalid", false), //
NORMAL(100, "prematureClosureReasonType.normal", false), //
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
private final Integer value;
private final String code;
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
* waive-penalty flag also sent -- see the class javadoc. */
private final boolean autoWaived;
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
this.value = value;
this.code = code;
this.autoWaived = autoWaived;
}
public static PrematureClosureReasonType fromInt(final Integer v) {
if (v == null) {
return INVALID;
}
return switch (v) {
case 100 -> NORMAL;
case 200 -> PREMATURE_EXTENSION;
case 300 -> CONVERSION_TO_ANOTHER_TD;
case 400 -> DECEASED_SETTLEMENT;
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
default -> INVALID;
};
}
public boolean isInvalid() {
return this.equals(INVALID);
}
}
@@ -287,6 +287,15 @@ public class SavingsApiConstants {
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
public static final String validForPeriodParamName = "validForPeriod";
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
public static final String odMinLimitParamName = "odMinLimit";
public static final String odSecurityTypeParamName = "odSecurityType";
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
public static final String odExcessAllowedParamName = "odExcessAllowed";
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
public static final String odPenalRateParamName = "odPenalRate";
// Status of Account fields
public static final String accountStatusParamName = "accountStatus";
@@ -234,6 +234,20 @@ public final class SavingsAccountData implements Serializable {
return this.transferInterestToLinkedAccount;
}
/**
* True for a recurring deposit opened under a product whose Interest Calculation Type is "Simple": its interest
* never compounds. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean simpleInterest;
public void setSimpleInterest(final boolean simpleInterest) {
this.simpleInterest = simpleInterest;
}
public boolean isSimpleInterest() {
return this.simpleInterest;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -153,6 +153,11 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private String avgBalanceCalcPeriod;
@Getter @Setter private String validForPeriod;
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
@Getter @Setter private BigDecimal odMinLimit;
@Getter @Setter private String odSecurityType;
@Getter @Setter private Boolean odExcessAllowed;
@Getter @Setter private BigDecimal odExcessLimitPercent;
@Getter @Setter private BigDecimal odPenalRate;
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
@Getter @Setter private Map<String, Object> accountFacilityConfig;
@@ -383,6 +388,11 @@ public final class SavingsProductData implements Serializable {
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
target.setValidForPeriod(source.getValidForPeriod());
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
target.setOdMinLimit(source.getOdMinLimit());
target.setOdSecurityType(source.getOdSecurityType());
target.setOdExcessAllowed(source.getOdExcessAllowed());
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
target.setOdPenalRate(source.getOdPenalRate());
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
@@ -180,6 +180,12 @@ public final class SavingsHelper {
immediateWithdrawalOfInterest);
}
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) {
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,
immediateWithdrawalOfInterest, simpleInterest);
}
public Collection<Long> fetchPostInterestTransactionIds(Long accountId) {
return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId);
}
@@ -40,6 +40,18 @@ public class CompoundInterestHelper {
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled) {
return calculateInterestForAllPostingPeriods(currency, allPeriods, lockUntil, interestTransferEnabled, false);
}
/**
* @param simpleInterest
* - when true, earned interest is never added back to the balance it is earned on, so every period
* earns on the principal alone (a recurring deposit whose product's Interest Calculation Type is
* "Simple"). Compounding and posting periods still decide when interest is recognised and posted;
* they just stop changing how much is earned.
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled, final boolean simpleInterest) {
// sum up the 'rounded' values that are posted each posting period
Money interestEarned = Money.zero(currency);
@@ -47,7 +59,8 @@ public class CompoundInterestHelper {
// total interest earned in previous periods but not yet recognised
BigDecimal compoundedInterest = BigDecimal.ZERO;
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
for (final PostingPeriod postingPeriod : allPeriods) {
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
@@ -67,4 +80,25 @@ public class CompoundInterestHelper {
return interestEarned;
}
/**
* Interest values that refuse to compound: whatever a compounding period hands over to be compounded is dropped,
* so the balance interest is earned on never includes interest already earned.
*/
private static final class SimpleInterestValues extends CompoundInterestValues {
SimpleInterestValues() {
super(BigDecimal.ZERO, BigDecimal.ZERO);
}
@Override
public void setCompoundedInterest(final BigDecimal interestToBeCompounded) {
super.setCompoundedInterest(BigDecimal.ZERO);
}
@Override
public boolean isSimpleInterest() {
return true;
}
}
}
@@ -50,4 +50,9 @@ public class CompoundInterestValues {
this.uncompoundedInterest = BigDecimal.ZERO;
}
/** True when earned interest must never earn interest itself - not across periods, and not within one either. */
public boolean isSimpleInterest() {
return false;
}
}
@@ -94,6 +94,18 @@ public final class DailyCompoundingPeriod implements CompoundingPeriod {
return interestEarned;
}
/** Interest on each day's balance alone, none of it added to the balance - for simple interest. */
public BigDecimal calculateSimpleInterest(final BigDecimal interestRateAsFraction, final long daysInYear,
final BigDecimal minBalanceForInterestCalculation, final BigDecimal overdraftInterestRateAsFraction,
final BigDecimal minOverdraftForInterestCalculation) {
BigDecimal interestEarned = BigDecimal.ZERO;
for (final EndOfDayBalance balance : this.endOfDayBalances) {
interestEarned = interestEarned.add(balance.calculateInterestOnBalance(BigDecimal.ZERO, interestRateAsFraction, daysInYear,
minBalanceForInterestCalculation, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation));
}
return interestEarned;
}
@Override
public LocalDateInterval getPeriodInterval() {
return this.periodInterval;
@@ -301,10 +301,17 @@ public final class PostingPeriod {
// to be applied to the balanced for interest calculation
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
// A daily compounding period compounds day by day inside itself, so simple interest has to bypass it - zeroing
// what is carried between periods alone still left a month's interest earning interest within the month.
final BigDecimal interestUnrounded = compoundInterestValues.isSimpleInterest()
&& compoundingPeriod instanceof DailyCompoundingPeriod dailyPeriod
? dailyPeriod.calculateSimpleInterest(this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount())
: compoundingPeriod.calculateInterest(this.interestCompoundingType, this.interestCalculationType,
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
case TRANSFER_TO_GL:
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
break;
}
return optionData;
}
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
@Column(name = "status_enum", nullable = false)
private Integer statusEnum = 100;
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
// 600=BlockDebit
@Column(name = "sub_status_enum", nullable = false)
private Integer subStatusEnum = 0;
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
// active) are the only statuses a second application on the same product should actually collide
// with.
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
}
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "office_id", nullable = false)
private Office office;
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
@Column(name = "transaction_type_enum", nullable = false)
private Integer transactionTypeEnum;
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
return txn;
}
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate) {
CurrentAccountTransaction txn = new CurrentAccountTransaction();
txn.currentAccount = account;
txn.office = office;
txn.transactionTypeEnum = 19;
txn.amount = amount;
txn.transactionDate = transactionDate;
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
txn.submittedOnDate = transactionDate;
return txn;
}
// transactionTypeEnum = 7 (Reversal / Undo)
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
/**
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
*/
boolean escheat(Long accountId);
}
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
SavingsApiConstants.glAccountIdParamName));
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
/** How long an OD sanction on this scheme runs before it has to be renewed. */
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
/** Excess drawing is a small tolerance over the limit, never a second limit. */
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, null);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
}
if (null != isDormancyActive && isDormancyActive) {
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
}
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, product);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
}
}
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
/**
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
* as RBI requires for advances, or at most quarterly.
*
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
* checked against the scheme it will produce; on create it is null.
*/
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
final CurrentProduct product) {
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
}
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
: product != null && product.isAllowOverdraft();
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
product == null ? null : product.getOverdraftLimit());
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
product == null ? null : product.getMinOverdraftForInterestCalculation());
if (!allowOverdraft) {
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
.zeroOrPositiveAmount();
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
return;
}
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
}
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
final String securityType = stringOrStored(element, odSecurityTypeParamName,
product == null ? null : product.getOdSecurityType());
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
: product != null && product.isOdExcessAllowed();
if (excessAllowed) {
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
product == null ? null : product.getOdExcessLimitPercent());
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
.notGreaterThanMax(MAX_EXCESS_PERCENT);
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
product == null ? null : product.getOdPenalRate());
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
}
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
: product == null ? null : product.getInterestPostingPeriodType();
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
SavingsPostingInterestPeriodType.QUATERLY.getValue());
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
: product == null ? null : product.getInterestCalculationDaysInYearType();
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
}
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
: stored;
}
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
: stored;
}
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
}
}
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import com.google.gson.JsonArray;
import jakarta.persistence.CascadeType;
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private boolean odAccountClosedZeroBalanceInDayClosing = false;
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
@Column(name = "od_min_limit", scale = 6, precision = 19)
private BigDecimal odMinLimit;
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
@Column(name = "od_security_type", length = 20)
private String odSecurityType;
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
@Column(name = "od_excess_allowed")
private boolean odExcessAllowed = false;
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
private BigDecimal odExcessLimitPercent;
@Column(name = "od_penal_rate", scale = 6, precision = 19)
private BigDecimal odPenalRate;
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
private CurrentProductFacilityConfig facilityConfig;
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
this.odAccountClosedZeroBalanceInDayClosing = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
actualChanges.put(odMinLimitParamName, newValue);
this.odMinLimit = newValue;
}
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
actualChanges.put(odSecurityTypeParamName, newValue);
this.odSecurityType = newValue;
}
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
actualChanges.put(odExcessAllowedParamName, newValue);
this.odExcessAllowed = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
actualChanges.put(odExcessLimitPercentParamName, newValue);
this.odExcessLimitPercent = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
actualChanges.put(odPenalRateParamName, newValue);
this.odPenalRate = newValue;
}
if (!this.odExcessAllowed) {
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
// Clear OD-specific fields when overdraft is disabled
if (!this.allowOverdraft) {
this.avgBalanceCalcPeriod = null;
this.validForPeriod = null;
this.odAccountClosedZeroBalanceInDayClosing = false;
this.odMinLimit = null;
this.odSecurityType = null;
this.odExcessAllowed = false;
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
return actualChanges;
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
public BigDecimal getOdMinLimit() { return odMinLimit; }
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
public String getOdSecurityType() { return odSecurityType; }
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
public boolean isOdExcessAllowed() { return odExcessAllowed; }
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
public BigDecimal getOdPenalRate() { return odPenalRate; }
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
}
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
}
if (command.parameterExists(odMinLimitParamName)) {
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
}
if (command.parameterExists(odSecurityTypeParamName)) {
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
}
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
product.setOdExcessAllowed(excessAllowed);
if (excessAllowed) {
if (command.parameterExists(odExcessLimitPercentParamName)) {
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
}
if (command.parameterExists(odPenalRateParamName)) {
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
}
}
}
return product;
@@ -0,0 +1,115 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentproduct.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
*
* <p>
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
* the two can never meet.
*
* <p>
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
*/
@Component
@RequiredArgsConstructor
public class CurrentProductGlMappingWriter {
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
static {
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
}
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
private final JdbcTemplate jdbcTemplate;
/**
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
*
* @param accountingRule
* the product's accounting rule after this request (1 = NONE)
*/
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (accountingRule == null || accountingRule == 1) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
return changes;
}
final JsonElement parsed = command.parsedJson();
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
final String param = entry.getKey();
if (!json.has(param)) {
continue;
}
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
final Long existing = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL",
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
if (java.util.Objects.equals(existing, glAccountId)) {
continue;
}
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
if (glAccountId != null) {
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
}
changes.put(param, glAccountId);
}
return changes;
}
/** Removes every mapping of a deleted product. */
public void deleteAll(final Long productId) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
}
}
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
productData.setValidForPeriod(rs.getString("validForPeriod"));
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
productData.setOdSecurityType(rs.getString("odSecurityType"));
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final CurrentProductGlMappingWriter glMappingWriter;
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
}
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
DepositAccountType.SAVINGS_DEPOSIT);
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
}
// accounting related changes
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
DepositAccountType.SAVINGS_DEPOSIT);
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
product.getAccountingType());
changes.putAll(accountingMappingChanges);
if (!changes.isEmpty()) {
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
this.currentProductRepository.delete(product);
this.glMappingWriter.deleteAll(productId);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
}
}
@@ -18,20 +18,29 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
private final ContentStoreService storeService;
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
@@ -52,9 +61,39 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
final var is = storeService.download(doc.getLocation());
try {
final var is = storeService.download(doc.getLocation());
return documentMapper.map(doc, is);
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_document row survives but the file it points at does not — the usual cause is
// a database copied between environments without its file store (see the identical
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
// has no content", not a server fault, so report it the same way as a missing document
// instead of letting a raw ContentStoreException surface as a 500.
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
entityType, entityId);
throw new DocumentNotFoundException(entityType, entityId, documentId);
}
throw new DocumentInvalidRequestException(e);
}
}
return documentMapper.map(doc, is);
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
}
@Override
@@ -0,0 +1,189 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
* then principal.
*
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
* trying to model that option only the basic one for now.
*/
@SuppressWarnings("unused")
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
public String getCode() {
return STRATEGY_CODE;
}
@Override
public String getName() {
return STRATEGY_NAME;
}
/**
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
charges);
}
/**
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
transactionMappings, charges);
}
/**
* For normal on-time repayments, pays off interest first, then principal.
*/
@Override
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
if (loanTransaction.isChargesWaiver()) {
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
loanTransaction.getPenaltyChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
loanTransaction.getFeeChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
} else if (loanTransaction.isInterestWaiver()) {
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else if (loanTransaction.isChargePayment()) {
if (loanTransaction.isPenaltyPayment()) {
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
}
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
@Override
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(currency);
Money interestPortion = Money.zero(currency);
Money feeChargesPortion = Money.zero(currency);
Money penaltyChargesPortion = Money.zero(currency);
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
if (transactionAmountRemaining.isGreaterThanZero()) {
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.starter;
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
@Override
protected boolean matches(FineractProperties properties) {
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
}
}
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
private final LoanProductGuaranteeData productGuaranteeData;
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
private LoanProductConfigurableAttributes allowAttributeOverrides;
/**
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
*/
private Map<String, Object> advanceExtensions;
private final boolean syncExpectedWithDisbursementDate;
private final boolean isEqualAmortization;
private final BigDecimal fixedPrincipalPercentagePerInstallment;
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
this.advanceExtensions = advanceExtensions;
}
public Map<String, Object> getAdvanceExtensions() {
return this.advanceExtensions;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,471 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
* (security &amp; margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
* loaded on the hot loan-origination path, so it is deliberately left untouched.
*
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_advance_ext")
public class LoanProductAdvanceExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification -------------------------------------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "priority_sector")
private String prioritySector;
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "kfs_enabled")
private Boolean kfsEnabled;
@Column(name = "apr_enabled")
private Boolean aprEnabled;
// ---- facility -------------------------------------------------------
@Column(name = "facility_type")
private String facilityType;
// ---- security & margin ----------------------------------------------
@Column(name = "security_type")
private String securityType;
@Column(name = "charge_type")
private String chargeType;
@Column(name = "eligible_amount_basis")
private String eligibleAmountBasis;
@Column(name = "margin_pct")
private BigDecimal marginPct;
@Column(name = "ltv_pct")
private BigDecimal ltvPct;
@Column(name = "dp_method")
private String dpMethod;
// ---- lien -----------------------------------------------------------
@Column(name = "lien_required")
private Boolean lienRequired;
@Column(name = "lien_marking_stage")
private String lienMarkingStage;
@Column(name = "lien_certificate_required")
private Boolean lienCertificateRequired;
@Column(name = "lien_certificate_prefix")
private String lienCertificatePrefix;
@Column(name = "print_certificate_on_disbursement")
private Boolean printCertificateOnDisbursement;
// ---- interest (extras; rate bounds live on the core product) --------
@Column(name = "interest_basis")
private String interestBasis;
@Column(name = "spread_pct")
private BigDecimal spreadPct;
@Column(name = "interest_charged_on")
private String interestChargedOn;
@Column(name = "interest_posting_frequency")
private String interestPostingFrequency;
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
@Column(name = "penal_applicable")
private Boolean penalApplicable;
@Column(name = "penal_rate_pct")
private BigDecimal penalRatePct;
@Column(name = "penal_applied_on")
private String penalAppliedOn;
@Column(name = "penal_grace_days")
private Integer penalGraceDays;
@Column(name = "npa_trigger")
private String npaTrigger;
@Column(name = "interest_after_npa")
private String interestAfterNpa;
@Column(name = "undebited_interest_gl_id")
private Long undebitedInterestGlId;
// ---- renewal & closure ----------------------------------------------
@Column(name = "renewal_allowed")
private Boolean renewalAllowed;
@Column(name = "renewal_period_months")
private Integer renewalPeriodMonths;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
@Column(name = "preclosure_allowed")
private String preclosureAllowed;
@Column(name = "lock_in_months")
private Integer lockInMonths;
@Column(name = "interest_on_preclosure")
private String interestOnPreclosure;
// ---- monitoring -----------------------------------------------------
@Column(name = "revaluation_freq_months")
private Integer revaluationFreqMonths;
@Column(name = "insurance_required")
private Boolean insuranceRequired;
@Column(name = "stock_statement_freq_months")
private Integer stockStatementFreqMonths;
// ---- cheque book ----------------------------------------------------
@Column(name = "cheque_book_enabled")
private Boolean chequeBookEnabled;
@Column(name = "cheque_type")
private String chequeType;
@Column(name = "default_cheque_book_size")
private Integer defaultChequeBookSize;
@Column(name = "pps_threshold")
private BigDecimal ppsThreshold;
@Column(name = "pps_mandatory_above")
private BigDecimal ppsMandatoryAbove;
// ---- share (co-op) --------------------------------------------------
@Column(name = "share_capital_gl_id")
private Long shareCapitalGlId;
// ---- security & lien extras -----------------------------------------
/** Pledgeable deposit product names, joined with "|". */
@Column(name = "eligible_deposit_products")
private String eligibleDepositProducts;
@Column(name = "lien_tenure_not_exceed_maturity")
private Boolean lienTenureNotExceedMaturity;
@Column(name = "lien_auto_release_on_closure")
private Boolean lienAutoReleaseOnClosure;
@Column(name = "lien_allow_multiple_deposits")
private Boolean lienAllowMultipleDeposits;
@Column(name = "lien_adjust_from_maturity")
private Boolean lienAdjustFromMaturity;
@Column(name = "lien_allow_third_party")
private Boolean lienAllowThirdParty;
@Column(name = "lien_allow_renewal_under_lien")
private Boolean lienAllowRenewalUnderLien;
// ---- limit & tenure extras ------------------------------------------
@Column(name = "repayment_type")
private String repaymentType;
@Column(name = "appropriation_order")
private String appropriationOrder;
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_after_due_date")
private Boolean allowDebitAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- interest extras ------------------------------------------------
@Column(name = "interest_calc_method")
private String interestCalcMethod;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "interest_posting_day")
private String interestPostingDay;
// ---- NPA upgrade rules ----------------------------------------------
@Column(name = "npa_upgrade_only_all_arrears")
private Boolean npaUpgradeOnlyAllArrears;
@Column(name = "npa_keep_standard_if_deposit_covers")
private Boolean npaKeepStandardIfDepositCovers;
// ---- cheque book full config ----------------------------------------
/** Allowed book sizes (leaves), joined with "|". */
@Column(name = "cheque_book_sizes")
private String chequeBookSizes;
@Column(name = "cheque_max_books_outstanding")
private Integer chequeMaxBooksOutstanding;
@Column(name = "cheque_reissue_when_leaves")
private Integer chequeReissueWhenLeaves;
@Column(name = "cheque_first_book_free")
private Boolean chequeFirstBookFree;
@Column(name = "cheque_free_books_per_year")
private Integer chequeFreeBooksPerYear;
@Column(name = "cheque_name_printed")
private String chequeNamePrinted;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "cheque_stop_payment_allowed")
private Boolean chequeStopPaymentAllowed;
@Column(name = "cheque_maintain_stock_register")
private Boolean chequeMaintainStockRegister;
@Column(name = "cheque_allow_withdrawal_slip")
private Boolean chequeAllowWithdrawalSlip;
@Column(name = "cheque_block_npa")
private Boolean chequeBlockNpa;
@Column(name = "cheque_block_limit_expired")
private Boolean chequeBlockLimitExpired;
@Column(name = "cheque_block_over_dp")
private Boolean chequeBlockOverDp;
@Column(name = "cheque_block_frozen")
private Boolean chequeBlockFrozen;
@Column(name = "cheque_block_lien_released")
private Boolean chequeBlockLienReleased;
// ---- Loans A/C (EMI) wizard fields -------------------------------------
@Column(name = "loan_security_mandatory")
private String securityMandatory;
@Column(name = "loan_guarantors_required")
private Integer guarantorsRequired;
@Column(name = "loan_co_applicant")
private String coApplicant;
@Column(name = "loan_third_party_security")
private String thirdPartySecurity;
@Column(name = "loan_legal_opinion_required")
private Boolean legalOpinionRequired;
@Column(name = "loan_property_insurance_required")
private Boolean propertyInsuranceRequired;
@Column(name = "loan_register_charge")
private Boolean registerCharge;
@Column(name = "loan_release_security_on_closure")
private Boolean releaseSecurityOnClosure;
@Column(name = "loan_allow_multiple_securities")
private Boolean allowMultipleSecurities;
@Column(name = "loan_allow_third_party_deposit")
private Boolean allowThirdPartyDeposit;
@Column(name = "loan_min_age")
private Integer minAge;
@Column(name = "loan_max_age_at_maturity")
private Integer maxAgeAtMaturity;
@Column(name = "loan_min_cibil")
private Integer minCibil;
@Column(name = "loan_min_monthly_income")
private BigDecimal minMonthlyIncome;
@Column(name = "loan_max_foir")
private BigDecimal maxFoir;
@Column(name = "loan_max_loans_per_customer")
private Integer maxLoansPerCustomer;
@Column(name = "loan_disbursement_type")
private String disbursementType;
@Column(name = "loan_disbursement_mode")
private String disbursementMode;
@Column(name = "loan_undisbursed_interest")
private String undisbursedInterest;
@Column(name = "loan_pre_emi_interest")
private Boolean preEmiInterest;
@Column(name = "loan_emi_due_day")
private String emiDueDay;
@Column(name = "loan_holiday_rule")
private String holidayRule;
@Column(name = "loan_moratorium_months")
private Integer moratoriumMonths;
@Column(name = "loan_moratorium_interest")
private String moratoriumInterest;
@Column(name = "loan_variable_installments")
private Boolean variableInstallments;
@Column(name = "loan_repayment_mode")
private String repaymentMode;
@Column(name = "loan_linked_account_type")
private String linkedAccountType;
@Column(name = "loan_pdc_collection")
private String pdcCollection;
@Column(name = "loan_mandate_required")
private Boolean mandateRequired;
@Column(name = "loan_advance_emi")
private String advanceEmi;
@Column(name = "loan_rate_type")
private String rateType;
@Column(name = "loan_register_particular")
private String registerParticular;
@Column(name = "loan_apply_cibil_adjustment")
private Boolean applyCibilAdjustment;
@Column(name = "loan_rate_resolved_at")
private String rateResolvedAt;
@Column(name = "loan_calc_period")
private String calcPeriod;
@Column(name = "loan_interest_starts_from")
private String interestStartsFrom;
@Column(name = "loan_compounding")
private String compounding;
@Column(name = "loan_benchmark")
private String benchmark;
@Column(name = "loan_reset_frequency")
private String resetFrequency;
@Column(name = "loan_reset_change")
private String resetChange;
@Column(name = "loan_part_prepayment_allowed")
private Boolean partPrepaymentAllowed;
@Column(name = "loan_min_part_payment")
private BigDecimal minPartPayment;
@Column(name = "loan_allowed_on")
private String allowedOn;
@Column(name = "loan_part_payments_per_year")
private String partPaymentsPerYear;
@Column(name = "loan_part_prepayment_mode")
private String partPrepaymentMode;
@Column(name = "loan_foreclosure_floating_individual")
private BigDecimal foreclosureFloatingIndividual;
@Column(name = "loan_foreclosure_fixed_individual")
private BigDecimal foreclosureFixedIndividual;
@Column(name = "loan_foreclosure_non_individual")
private BigDecimal foreclosureNonIndividual;
@Column(name = "loan_foreclosure_takeover")
private BigDecimal foreclosureTakeover;
@Column(name = "loan_foreclosure_quote_valid_days")
private Integer foreclosureQuoteValidDays;
@Column(name = "loan_auto_close_on_zero_balance")
private Boolean autoCloseOnZeroBalance;
@Column(name = "loan_noc_letter")
private String nocLetter;
@Column(name = "loan_restructuring")
private String restructuring;
@Column(name = "loan_release_collateral_on_closure")
private Boolean releaseCollateralOnClosure;
@Column(name = "loan_top_up_allowed")
private Boolean topUpAllowed;
@Column(name = "loan_balance_transfer_out_allowed")
private Boolean balanceTransferOutAllowed;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,16 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
}
@@ -0,0 +1,76 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%.
* Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads
* by {@code displayOrder}.
*/
@Entity
@Table(name = "m_loan_rate_register_cibil_slab")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterCibilSlab {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_id", nullable = false)
private Long effectiveId;
@Column(name = "score_from", nullable = false)
private Integer scoreFrom;
@Column(name = "score_to", nullable = false)
private Integer scoreTo;
@Column(name = "tier", length = 30, nullable = false)
private String tier;
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "adjustment_pct", nullable = false)
private BigDecimal adjustmentPct;
@Column(name = "display_order", nullable = false)
private Integer displayOrder;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier,
final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) {
final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab();
s.effectiveId = effectiveId;
s.scoreFrom = scoreFrom;
s.scoreTo = scoreTo;
s.tier = tier;
s.slabType = slabType != null ? slabType : "Flat";
s.adjustmentPct = adjustmentPct;
s.displayOrder = displayOrder;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterCibilSlabRepository extends JpaRepository<LoanRateRegisterCibilSlab, Long> {
/** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */
List<LoanRateRegisterCibilSlab> findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId);
}
@@ -0,0 +1,94 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the
* {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with
* {@code isActive=true} and deactivates the previous active row ({@code isActive=false,
* effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}.
*
* The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory};
* the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's
* id and cascade-deleted with it.
*/
@Entity
@Table(name = "m_loan_rate_register_effective")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterEffective {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "effective_from_date", nullable = false)
private LocalDate effectiveFromDate;
@Column(name = "effective_to_date")
private LocalDate effectiveToDate;
@Column(name = "currency_code", length = 3, nullable = false)
private String currencyCode;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "fallback_no_cibil_slab", nullable = false)
private Integer fallbackNoCibilSlab;
@Column(name = "fallback_below_min", length = 20, nullable = false)
private String fallbackBelowMin;
@Column(name = "note", length = 500)
private String note;
@Column(name = "created_by_id")
private Long createdById;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode,
final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) {
final LoanRateRegisterEffective e = new LoanRateRegisterEffective();
e.name = name;
e.effectiveFromDate = effectiveFromDate;
e.currencyCode = currencyCode != null ? currencyCode : "INR";
e.isActive = true;
e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2;
e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block";
e.note = note;
e.createdById = createdById;
e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return e;
}
/** Flip this version to inactive and close its effective window. Called when a newer version is approved. */
public void deactivate(final LocalDate toDate) {
this.isActive = false;
this.effectiveToDate = toDate;
}
}
@@ -0,0 +1,24 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterEffectiveRepository extends JpaRepository<LoanRateRegisterEffective, Long> {
/** All active versions, newest-effective first. At most one row in a healthy system. */
List<LoanRateRegisterEffective> findByIsActiveTrueOrderByEffectiveFromDateDesc();
/** The current active version, if any. */
Optional<LoanRateRegisterEffective> findFirstByIsActiveTrueOrderByEffectiveFromDateDesc();
/** Version history list — all versions, newest first. */
List<LoanRateRegisterEffective> findAllByOrderByEffectiveFromDateDesc();
}
@@ -0,0 +1,52 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a
* property of the particular, so it lives here rather than on each rate-register version. Kept as a
* plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity
* graph into the loan module.
*/
@Entity
@Table(name = "m_loan_rate_register_product")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "sr_no", nullable = false)
private Integer srNo;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "created_date")
private LocalDateTime createdDate;
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterProductRepository extends JpaRepository<LoanRateRegisterProduct, Long> {
/** Active particulars in chart order. */
List<LoanRateRegisterProduct> findByIsActiveTrueOrderBySrNoAsc();
}
@@ -0,0 +1,98 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A single cell of a rate register version — one row per {product × amount-slab}. Product and slab
* metadata are denormalised so one query can rebuild the full matrix without a join. The cell holds
* either a plain numeric rate, a formula string ({@code "1% + FD"}) resolved at sanction, or
* N.A. (both value and formula null, {@code isNa=true}) to mean "slab not offered".
*/
@Entity
@Table(name = "m_loan_rate_register_slab_history")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_id", nullable = false)
private Long effectiveId;
// ---- product dimension ----------------------------------------------
/** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */
@Column(name = "product_id")
private Long productId;
@Column(name = "product_sr_no", nullable = false)
private Integer productSrNo;
@Column(name = "product_name", length = 200, nullable = false)
private String productName;
// ---- amount-slab dimension ------------------------------------------
@Column(name = "slab_order", nullable = false)
private Integer slabOrder;
@Column(name = "slab_label", length = 60, nullable = false)
private String slabLabel;
@Column(name = "amount_upto")
private BigDecimal amountUpto;
// ---- cell value (exactly one of value / formula is populated; both null + isNa=true means N.A.) ----
@Column(name = "rate_value")
private BigDecimal rateValue;
@Column(name = "rate_formula", length = 50)
private String rateFormula;
@Column(name = "is_formula", nullable = false)
private Boolean isFormula;
@Column(name = "is_na", nullable = false)
private Boolean isNa;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo,
final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto,
final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) {
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
s.effectiveId = effectiveId;
s.productId = productId;
s.productSrNo = productSrNo;
s.productName = productName;
s.slabOrder = slabOrder;
s.slabLabel = slabLabel;
s.amountUpto = amountUpto;
s.rateValue = rateValue;
s.rateFormula = rateFormula;
s.isFormula = isFormula;
s.isNa = isNa;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterSlabHistoryRepository extends JpaRepository<LoanRateRegisterSlabHistory, Long> {
/** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */
List<LoanRateRegisterSlabHistory> findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId);
}
+2
View File
@@ -81,6 +81,8 @@ dependencies {
'org.apache.avro:avro',
'jakarta.ws.rs:jakarta.ws.rs-api',
'org.glassfish.jersey.media:jersey-media-multipart',
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
'org.glassfish.jersey.media:jersey-media-sse',
'org.springframework:spring-jms',
'org.springframework:spring-context-support',
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
"glAccountId", glAccountId);
}
}
/**
* Refuses to draw more out of a holding account than it holds.
*
* <p>
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
* liability with a negative balance is not a small number - it says the bank owes less than
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
* and the figure is small enough to sit unnoticed inside a head that totals positive.
*
* <p>
* Checked here rather than at each call site because the accounts this protects are reached by
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
* the next one added will not have.
*
* @param glAccountId the account being drawn on.
* @param amount how much the caller wants to take out.
* @param what the account's role in the caller's own words, for the message a clerk reads.
*/
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
final java.math.BigDecimal amount, final String what) {
if (glAccountId == null || amount == null || amount.signum() <= 0) {
return;
}
// Credits less debits: these are liability-natured holding accounts, so the balance is what
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
// reversal pair nets to nothing, and counting either side alone moves the balance by the
// whole amount.
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
FROM acc_gl_journal_entry
WHERE account_id = ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""", java.math.BigDecimal.class, glAccountId);
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
if (balance.compareTo(amount) < 0) {
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
+ "difference would leave the account with a negative balance, which a "
+ "holding account cannot have.",
"glAccountId", glAccountId, amount);
}
}
}
@@ -60,6 +60,7 @@ import org.apache.fineract.infrastructure.core.api.DateParam;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.DateFormat;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -229,6 +230,11 @@ public class JournalEntriesApiResource {
@PathParam("transactionId") @Parameter(description = "transactionId") final String transactionId,
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
CommandProcessingResult result;
if (is(commandParam, "reverse")) {
throw new PlatformDataIntegrityException("error.msg.journalentry.reverse.not.allowed",
"Reversing a journal entry is not allowed.");
}
if (is(commandParam, "reverse")) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().reverseJournalEntry(transactionId).withJson(jsonRequestBody)
.build();
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
// cash-based path.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the same helper the cash processor uses, so a posting states
// the whole chain the product is configured for -- out of the payable, through
// the interest-paid account, into the member's balance -- rather than the single
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
}
}
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the payable and paid accounts the product names, so the society
// can read interest declared but not yet credited as its own balance. Collapses
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, amount, isReversal);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
}
}
@@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
// m_current_account -- newer write paths tag current-account entries with it
// instead of reusing SAVING's type, specifically so this case needs no guessing.
//
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
// and current-account entries by older write paths not yet updated to the above --
// current products used to deliberately share SAVING's GL type space (see
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
// m_current_account each have their own id sequence, so an entity_id can be a real
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
// account's number even for an entry that is genuinely a savings one.
// savings_transaction_id is the one unambiguous signal available for those older
// entries -- only a real savings transaction sets it -- so it decides first; only
// when it's absent does the current-account guess apply.
.append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ")
.append(" END as savingsAccountNo ");
@@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -687,6 +687,16 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
}
private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command) {
validateBusinessRulesForJournalEntries(command, true);
}
/**
* @param requireBalanced
* false for opening balances: every leg is posted against the contra account, so the books always
* balance, and the submitted legs legitimately differ by the locked (denomination-managed) cash
* account's opening balance that is already posted and can never be part of this request.
*/
private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command, final boolean requireBalanced) {
// check if date of Journal entry is valid
final LocalDate transactionDate = command.getTransactionDate();
// shouldn't be in the future
@@ -706,6 +716,26 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final SingleDebitOrCreditEntryCommand[] credits = command.getCredits();
final SingleDebitOrCreditEntryCommand[] debits = command.getDebits();
if (!requireBalanced) {
final List<SingleDebitOrCreditEntryCommand> entries = new ArrayList<>();
if (credits != null) {
entries.addAll(Arrays.asList(credits));
}
if (debits != null) {
entries.addAll(Arrays.asList(debits));
}
if (entries.isEmpty()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null);
}
for (final SingleDebitOrCreditEntryCommand entry : entries) {
if (entry.getAmount() == null || entry.getGlAccountId() == null) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, null, null,
null);
}
}
return;
}
// atleast one debit or credit must be present
if (credits == null || credits.length == 0 || debits == null || debits.length == 0) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null);
@@ -783,7 +813,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
validateBusinessRulesForJournalEntries(journalEntryCommand);
validateBusinessRulesForJournalEntries(journalEntryCommand, false);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/**
@@ -84,4 +84,8 @@ public final class LedgerTxnPrefix {
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
// ── Fixed deposits ────────────────────────────────────────────────────────
/** Approved fixed deposit interest posting report, for one product, branch and date. */
public static final String FD_INTEREST_POSTED = "FDINT";
}
@@ -294,8 +294,14 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
: null;
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
@@ -329,6 +335,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
break;
case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
// Interest Payable is saved on cash too, and optionally: interest posting routes
// through it on either basis (see AccountingProcessorHelper
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
// yet credited to the member reads as the bank's own liability rather than appearing
// only at the moment it is paid. Mapped only when the screen supplies it - a product
// naming none still posts straight to Savings Control, as it always did.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
}
break;
case ACCRUAL_PERIODIC:
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component;
@Path("/v1/loans")
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
public class LoanCOBCatchUpApiResource {
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
private final PlatformSecurityContext context;
@GET
@Path("oldest-cob-closed")
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
public Response executeLoanCOBCatchUp() {
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
return Response.status(Response.Status.BAD_REQUEST).build();
@@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return property.isEnabled();
}
/**
* Read on every run of the interest posting job and of the day close, so the two paths can never
* both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid
* from an approved report, with it off the job pays them directly as it always did.
*/
@Override
public boolean isFixedDepositInterestPostingApprovalEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled();
}
@Override
public Integer retrieveFinancialYearBeginningMonth() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). Same
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -222,7 +224,10 @@ public class SecurityConfig {
// currency
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies"))
// the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) -
// this matcher previously targeted POST, which meant PUT fell through to the generic
// fullyAuthenticated() catch-all with no permission check at all.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
// notes: read
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
@@ -355,11 +360,18 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
// payment type
// NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a
// second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and
// no PUT/DELETE matcher existed at all, so those requests fell through to the generic
// fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher
// per method, each requiring its own correct permission code.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
// mix: taxonomy
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
@@ -390,12 +402,41 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
// working days
// NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between
// WORKING and DAYS), which did not match the actual seeded permission codes
// (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS")
// sponsor banks
// NOTE: no matcher previously existed at all for this path - POST/PUT both fell
// through to the generic fullyAuthenticated() catch-all below with no permission
// check whatsoever.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK")
// sponsor DD GST/GL settings
// NOTE: no matcher previously existed at all for this path either. gl-mapping is a
// genuine upsert (one call both creates and updates the single settings row), so it is
// covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING")
// bank profile (institution profile)
// NOTE: no matcher previously existed at all for this path - the PUT relied solely on
// the in-resource validateHasPermissionTo() check, which this change replaces with the
// command-source pipeline. Added here for the same defense-in-depth already applied to
// Currency/Payment Type/Working Days.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE")
// branch profile
// NOTE: no matcher previously existed at all for this path either, same rationale as
// institution profile above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
private final TenantDataSourceFactory tenantDataSourceFactory;
private final Environment environment;
private final TenantInitialUserProvisioner initialUserProvisioner;
// DO NOT REMOVE! Required for liquibase custom task initialization
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
// configuration
// settings, and we should not use them to control the script order
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
if (brandNewTenant) {
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
}
}
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
tenant.getTenantIdentifier());
@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service.migration;
import java.util.Locale;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.useradministration.service.AppUserConstants;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
* public default password).
*
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
* break validation on every tenant that already ran it.
*
* Usernames become {@code <tenantIdentifier>_admin} and {@code <tenantIdentifier>_interop}. The system user keeps the
* {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it
* up by that name; only its password is changed. All three share one password: the value of
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
* otherwise {@link #DEFAULT_PASSWORD}.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantInitialUserProvisioner {
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
private static final long ADMIN_ID = 1L;
private static final long SYSTEM_ID = 2L;
private static final long INTEROP_ID = 3L;
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
private final Environment environment;
public void provision(final DataSource dataSource, final String tenantIdentifier) {
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
final String prefix = usernamePrefix(tenantIdentifier);
final String configured = environment.getProperty(PASSWORD_PROPERTY);
final boolean overridden = configured != null && !configured.isBlank();
final String password = overridden ? configured : DEFAULT_PASSWORD;
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final String encoded = encoder.encode(password);
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded);
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.",
tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
}
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
encodedPassword, id);
}
static String usernamePrefix(final String tenantIdentifier) {
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
return cleaned.isEmpty() ? "tenant" : cleaned;
}
}
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
@Override
public Collection<TellerData> getTellers(Long officeId) {
return retrieveAllTellers(false);
if (officeId == null) {
return retrieveAllTellers(false);
}
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
}
@Override
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
try {
this.context.authenticatedUser();
if (this.tellerRepositoryWrapper.existsAny()) {
throw new TellerAlreadyExistsException();
}
final Long officeId = command.longValueOfParameterNamed("officeId");
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
// final Office parent =
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
// its own cash, so a second branch needs a vault of its own.
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
throw new TellerAlreadyExistsException(tellerOffice.getName());
}
final Teller teller = Teller.fromJson(tellerOffice, command);
// pre save to generate id for use in office hierarchy
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
import org.springframework.stereotype.Component;
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) {
public String update(@Valid final WorkingDaysUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays()
.withJson(toApiJsonSerializer.serialize(request)).build();
final var command = new WorkingDaysUpdateCommand();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
command.setCommandId(System.currentTimeMillis());
command.setCreatedAt(Instant.now());
command.setPayload(request);
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
@GET
@@ -18,38 +18,46 @@
*/
package org.apache.fineract.organisation.workingdays.handler;
import java.util.Collections;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
@Service
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
@Autowired
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
}
@Transactional
@Override
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
WorkingDaysUpdateRequest request = command.getPayload();
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
if (changes == null) {
changes = Collections.emptyMap();
}
public CommandProcessingResult processCommand(final JsonCommand command) {
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
.recurrence(command.stringValueOfParameterNamed("recurrence"))
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
.extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments"))
.extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays"))
.build();
final Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(changes) //
.build();
}
}
@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.service;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
/**
* A book debit: money leaves a savings or current account by book entry, not as cash.
*
* <p>
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
* report shows.
*
* <p>
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
* transaction, so a later failure rolls the debit back with everything else.
*/
@Service
@RequiredArgsConstructor
public class AccountBookDebitService {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
/** Debits a savings account; returns the m_savings_account_transaction id. */
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo());
ps.setBigDecimal(7, balance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** Debits a current account; returns the m_current_account_transaction id. */
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId, final String note) {
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setString(5, refNo());
ps.setDate(6, sqlDate);
ps.setBigDecimal(7, balance.subtract(amount));
ps.setString(8, remarks);
ps.setLong(9, userId);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
accountId);
}
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
if (status != 300) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
kind + " account " + accountId + " is not active.", accountId);
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
final BigDecimal available = balance.subtract(onHold);
if (available.compareTo(amount) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
+ ", Requested: " + amount,
accountId);
}
return balance;
}
private static String refNo() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
}
}
@@ -27,6 +27,7 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
import com.google.common.collect.Lists;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -44,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -57,6 +59,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
@@ -73,11 +76,13 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@@ -100,6 +105,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
private final KycRestrictionGuard kycRestrictionGuard;
private final SavingsInterestTdsService savingsInterestTdsService;
@Transactional
@Override
@@ -145,6 +152,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
@@ -200,6 +208,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -405,11 +414,15 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
toSavingsAccount, withdrawal, deposit);
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = accountTransferDetails.getId();
// FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted.
this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit,
AccountTransferType.fromInt(accountTransferDTO.getTransferType()));
} else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
@@ -447,6 +460,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
toSavingsAccount, deposit, loanTransaction);
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
@@ -472,7 +486,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
toSavingsAccount.productId(), false, toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
@@ -481,6 +495,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
@@ -517,7 +532,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
@@ -545,10 +560,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
@@ -642,6 +657,26 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
return accountTransferDetails;
}
/**
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
* is left untouched.
*/
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
return;
}
final boolean isPreMatureClosure = false;
recurringDeposit.updateDepositAmount(deposit.getAmount());
recurringDeposit.handleScheduleInstallments(deposit);
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
}
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
}
@@ -681,6 +716,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) {
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -699,10 +736,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
account.getId(), currentTxnId, txnDate, amount);
}
return txn;
}
@@ -712,7 +748,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
*/
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -730,7 +768,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -738,7 +777,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Deposit amount into a current account and save the transaction.
*/
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount);
@@ -749,7 +790,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -783,7 +825,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
@@ -794,17 +836,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, counterpartEntityId,
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
currentTxnId, txnDate, amount);
} else {
// DR: counterpart (savings/current) GL CR: current account GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
currentTxnId, txnDate, amount);
}
}
@@ -847,6 +885,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -1,140 +1,144 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
SavingsInterestTdsService savingsInterestTdsService) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
savingsInterestTdsService);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
// m_savings_account and m_current_account each keep their own identity sequence, so the same
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
// be written for both savings and current-account entries alike, so esa/eca could both match the
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
// is what newer write paths tag current-account entries with instead, unambiguously; for older
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
// savings transaction sets it), checked before the ambiguous current/savings guess.
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
@@ -0,0 +1,67 @@
package org.apache.fineract.portfolio.approvals.api;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
* Numbers only: opening a tab still loads that tab's own list. The counting is in
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
*/
@Path("/v1/approvals/counts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ApprovalCountsApiResource {
private final PlatformSecurityContext context;
private final ApprovalCountsReadPlatformService readService;
private final ApprovalCountsStreamService streamService;
/**
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
*/
@GET
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
final AppUser user = this.context.authenticatedUser();
final CacheControl noStore = new CacheControl();
noStore.setNoStore(true);
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
}
/**
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
*/
@GET
@Path("stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
// Ask reverse proxies not to hold the events back.
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache, no-store");
try {
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
} catch (final RuntimeException e) {
sink.close();
throw e;
}
}
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.approvals.data;
/**
* One Approval Center tab and the question that counts what waits on it.
*
* @param key
* the tab's route segment, which is also the key its badge reads on the screen
* @param permission
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
* the tab
* @param sql
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
*/
public record ApprovalCounter(String key, String permission, String sql) {}
@@ -0,0 +1,27 @@
package org.apache.fineract.portfolio.approvals.data;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
* exactly when the numbers are, which is how a change is told from a repeat.
*/
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
private static final Gson GSON = new Gson();
public static ApprovalCountsData of(final Map<String, Long> counts) {
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
}
public String toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("total", this.total);
json.put("counts", this.counts);
json.put("version", this.version);
return GSON.toJson(json);
}
}
@@ -0,0 +1,22 @@
package org.apache.fineract.portfolio.approvals.data;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
/**
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
* be used from any thread.
*
* @param tenant
* the tenant the counts are read from
* @param visible
* the tabs this caller may see, in the order the Approval Center shows them
* @param params
* the named parameters the tabs' queries use
* @param group
* equal for callers who are counted identically, so they can share one query
*/
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
String group) {}
@@ -0,0 +1,122 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.List;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
/**
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
* tab, add one line here and one badge on the screen.
*
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
*/
public final class ApprovalCounterRegistry {
private ApprovalCounterRegistry() {}
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
+ "AND p.entity_name = aud.entity_name)) ";
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
+ "AND o.hierarchy LIKE :h)";
/** The one tab whose list is open to managers and head cashiers only. */
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
public static final List<ApprovalCounter> ALL = List.of(
// ── Fineract's own maker-checker and pending lists ────────────────────────────
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
new ApprovalCounter("client-approval", "READ_CLIENT",
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
new ApprovalCounter("loan-approval", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
new ApprovalCounter("loan-disbursal", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
+ "AND COALESCE(ca.terminate_true, false) = false "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
+ "WHERE p.status = 100"),
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
new ApprovalCounter("dds-approvals", null,
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-issue-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-reprint-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-revoke-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
new ApprovalCounter("risk-change-approvals", null,
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
}
@@ -0,0 +1,24 @@
package org.apache.fineract.portfolio.approvals.service;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
/** Reads how many requests wait on each Approval Center tab, in one statement. */
public interface ApprovalCountsReadPlatformService {
/**
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
* tenant of the current request.
*/
ApprovalCountsPlan planFor(AppUser user);
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
/**
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
* cost the database one query. {@code fresh} skips the held answer.
*/
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
}
@@ -0,0 +1,114 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
/**
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
/** Screens asking together read one answer instead of each running the statement. */
private static final long HOLD_MILLIS = 3_000L;
private record Held(long at, ApprovalCountsData data) {}
private final JdbcTemplate jdbcTemplate;
private final Map<String, Held> held = new ConcurrentHashMap<>();
@Override
public ApprovalCountsPlan planFor(final AppUser user) {
final List<ApprovalCounter> visible = new ArrayList<>();
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
if (isVisible(user, counter)) {
visible.add(counter);
}
}
final String hierarchy = user.getOffice().getHierarchy();
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
return new ApprovalCountsPlan(tenant, visible, params, group);
}
@Override
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
final Map<String, Long> counts = new LinkedHashMap<>();
final List<ApprovalCounter> visible = plan.visible();
if (!visible.isEmpty()) {
final StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < visible.size(); i++) {
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
}
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
plan.params());
for (int i = 0; i < visible.size(); i++) {
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
if (n > 0) {
counts.put(visible.get(i).key(), n);
}
}
}
return ApprovalCountsData.of(counts);
}
@Override
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
final ApprovalCountsPlan plan = planFor(user);
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
final long now = System.currentTimeMillis();
final Held recent = this.held.get(holdKey);
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
return recent.data();
}
final ApprovalCountsData data = retrieve(plan);
this.held.put(holdKey, new Held(now, data));
if (this.held.size() > 500) {
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
}
return data;
}
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
if (counter.permission() != null) {
final boolean allowed = counter.permission().startsWith("READ_")
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
: user.hasAnyPermission(counter.permission());
if (!allowed) {
return false;
}
}
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
}
private boolean isChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
}
}
@@ -0,0 +1,145 @@
package org.apache.fineract.portfolio.approvals.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.springframework.stereotype.Service;
/**
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
* numbers change, so it neither polls nor reloads.
*
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
* connection, which bounds what a browser accumulates on one.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsStreamService {
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
private static final long CHECK_MILLIS = 1_000L;
private static final long PING_MILLIS = 15_000L;
private static final long LIFETIME_MILLIS = 300_000L;
/** A screen listening, and the version of the numbers it was last sent. */
private static final class Listener {
private final ApprovalCountsPlan plan;
private final SseEventSink sink;
private final Sse sse;
private final long expiresAt;
private volatile String version;
private volatile long lastSent;
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
this.plan = plan;
this.sink = sink;
this.sse = sse;
this.expiresAt = expiresAt;
}
}
private final ApprovalCountsReadPlatformService readService;
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
final Thread thread = new Thread(runnable, "approval-counts-stream");
thread.setDaemon(true);
return thread;
});
@PostConstruct
void startTicker() {
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stopTicker() {
this.ticker.shutdownNow();
this.listeners.forEach(this::close);
}
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
try {
publish(listener, this.readService.retrieve(plan));
} catch (final RuntimeException e) {
sink.close();
throw e;
}
this.listeners.add(listener);
}
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
private void tick() {
if (this.listeners.isEmpty()) {
return;
}
final long now = System.currentTimeMillis();
final Map<String, ApprovalCountsData> computed = new HashMap<>();
for (final Listener listener : this.listeners) {
try {
if (listener.sink.isClosed() || now >= listener.expiresAt) {
close(listener);
continue;
}
ApprovalCountsData data = computed.get(listener.plan.group());
if (data == null) {
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
data = this.readService.retrieve(listener.plan);
computed.put(listener.plan.group(), data);
}
if (!data.version().equals(listener.version)) {
publish(listener, data);
} else if (now - listener.lastSent >= PING_MILLIS) {
listener.lastSent = now;
send(listener, listener.sse.newEventBuilder().comment("ping").build());
}
} catch (final RuntimeException e) {
close(listener);
} finally {
ThreadLocalContextUtil.clearTenant();
}
}
}
private void publish(final Listener listener, final ApprovalCountsData data) {
listener.version = data.version();
listener.lastSent = System.currentTimeMillis();
send(listener, listener.sse.newEvent("counts", data.toJson()));
}
private void send(final Listener listener, final OutboundSseEvent event) {
listener.sink.send(event).whenComplete((sent, failure) -> {
if (failure != null) {
close(listener);
}
});
}
private void close(final Listener listener) {
this.listeners.remove(listener);
try {
listener.sink.close();
} catch (final RuntimeException ignored) {
// The screen is already gone.
}
}
}
@@ -46,6 +46,10 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -69,10 +73,28 @@ import org.springframework.transaction.annotation.Transactional;
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
/**
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
* own office. A person moved to another branch's teller keeps what they took at the old branch
* there.
*/
private static final String COUNTER_OFFICE_OF_STD = """
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
LIMIT 1),
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
""";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
private final SavingsInterestPostingReportService interestPostingReportService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -104,11 +126,26 @@ public class BranchDayRegisterApiResource {
result.put("officeId", officeId);
result.put("canOperate", canOperateDay(currentUser));
result.put("days", mapDays(rows));
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
result.put("today", DateUtils.getBusinessLocalDate().toString());
result.put("openDay", findOpenDay(officeId));
return gson.toJson(result);
}
// ── GET /v1/branchday/today ── the bank's "today" ─────────────────────────
/**
* The business date while enable-business-date is on, the tenant's own date while it is off.
* Every screen that defaults a date to "today" reads this, rather than the browser clock or the
* business-date API (which returns the stored date even when the feature is off). Open to any
* signed-in user — cashiers and clerks cannot read the configuration that says which applies.
*/
@GET
@Path("today")
public String today() {
this.context.authenticatedUser();
return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString()));
}
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
/**
@@ -121,7 +158,7 @@ public class BranchDayRegisterApiResource {
public String missed(@QueryParam("officeId") final Long officeIdParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
@@ -191,8 +228,8 @@ public class BranchDayRegisterApiResource {
// 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.
// wrong at every office but the first. A row whose officer has no cashier allocation counts
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
//
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
@@ -200,28 +237,30 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_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 = ?))
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_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 = ?))
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
// What will stop this close, so the screen can say so before the button is pressed rather than
// after. The same lists closeDay() refuses on, read-only here.
result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate));
// One call for the deposits: what is blocking, and the products and amounts behind it, so the
// branch sees what it is being asked to approve without leaving the close screen. Pricing a
// branch's deposits is the expensive part and is done once.
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
result.put("fdInterestPending", fdInterest.get("pending"));
result.put("fdInterestDue", fdInterest.get("due"));
return gson.toJson(result);
}
@@ -243,7 +282,7 @@ public class BranchDayRegisterApiResource {
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
DateUtils.getBusinessLocalDate());
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
@@ -437,7 +476,7 @@ public class BranchDayRegisterApiResource {
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
DateUtils.getBusinessLocalDate());
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
@@ -589,6 +628,43 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " is already closed.");
}
// ── This branch's savings interest reports must be approved before it closes ──
// Not overridable by a force-close reason: the branch posts its own customers' interest when
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!interestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
"Cannot close the day — this branch's savings interest posting report is not approved for: "
+ String.join(", ", interestPending)
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
}
// ── And its fixed deposit interest reports, on the same grounds ──
// Only while deposits are on the approval path at all; with that switched off the posting job
// pays them directly and there is nothing here to approve.
final List<String> fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!fdInterestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending",
"Cannot close the day — this branch's fixed deposit interest posting report is not approved for: "
+ String.join(", ", fdInterestPending)
+ ". Approve it under Reports → FD Interest Posting Reports, then close the day.");
}
// ── The head office closes last ──
// Every branch that opened the day closes first and posts its own interest; a branch that
// never opened is not waited for.
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
if (isHeadOffice) {
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
// still working and has not posted its interest.
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
if (!stillOpen.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
+ ". The head office closes last.");
}
}
// ── Ensure every cashier holding cash has been reconciled AND settled ──
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
// and if so, verify reconciliation and settlement records exist for the business date.
@@ -687,6 +763,45 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
// ── The branch posts its own approved savings interest as it closes ──
// The head office also posts what it approved for branches that did not open today. Entries
// are raised in the customers' own branch either way.
//
// Posted here, before the close is written, and synchronously, so the outcome is never
// silent: each report posts in a transaction of its own and commits, and if any fails the
// close is refused with the reason — the day stays open, the report stays APPROVED, and
// closing again once the cause is fixed posts it.
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!toPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
if (!interestFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// ── And the branch's approved fixed deposit interest, the same way ──
// Separate from the savings run so a deposit product with a GL problem cannot stop savings
// interest that has already posted cleanly, and so the refusal names which of the two failed.
final List<Long> fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!fdToPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size());
final List<String> fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost);
if (!fdFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed",
"Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// "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
@@ -716,11 +831,13 @@ public class BranchDayRegisterApiResource {
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
snapshotVaultDenominations(officeId, registerId, businessDate);
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("zeroBalanceDepositsClosed", depositsClosed);
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
@@ -728,6 +845,38 @@ public class BranchDayRegisterApiResource {
return gson.toJson(res);
}
/**
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
* - is marked Closed on that business date, so it no longer waits in the matured list.
*
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
*
* @return how many deposits were closed
*/
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
final int closed = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
FROM m_savings_product sp, m_client c
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
AND COALESCE(sa.account_balance_derived, 0) = 0
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
if (closed > 0) {
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
}
return closed;
}
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
/**
@@ -743,7 +892,7 @@ public class BranchDayRegisterApiResource {
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
? LocalDate.parse(businessDateParam)
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
: DateUtils.getBusinessLocalDate();
final List<Long> registerIds = jdbcTemplate.queryForList(
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
@@ -1004,6 +1153,9 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
// The close screen fetches this office's tellers with it; left out, it fetched every teller
// the user can see, and the head office counted each branch's vault as its own.
m.put("officeId", r.get("office_id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
@@ -1057,11 +1209,13 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
// By the counter that took the cash. A savings deposit carries no office_id, and the old
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
AND COALESCE(counter_office_id, office_id) = ?
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
@@ -16,6 +16,8 @@ import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -29,6 +31,7 @@ import org.apache.fineract.portfolio.denominationmaster.service.DenominationBrea
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
import org.apache.fineract.portfolio.client.data.ClientKycApiConstants;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -50,6 +53,12 @@ public class CashExchangeApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* Above this amount the customer's PAN must be recorded: Income Tax Rule 114B requires PAN for cash
* transactions of more than Rs 50,000 in a day, and RBI cash-handling guidelines follow it.
*/
static final BigDecimal PAN_MANDATORY_ABOVE = new BigDecimal("50000");
@POST
@Transactional
public String create(String body) {
@@ -78,15 +87,25 @@ public class CashExchangeApiResource {
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()
? json.get("customerIdProof").getAsString() : null;
final String customerName = trimToNull(json, "customerName");
final String customerMobile = trimToNull(json, "customerMobile");
final String panInput = trimToNull(json, "panNumber");
final String panNumber = panInput != null ? panInput.toUpperCase(java.util.Locale.ROOT) : null;
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
: new JsonArray();
final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray();
final BigDecimal receivedTotal = sumTotal(received);
final BigDecimal givenTotal = sumTotal(given);
// Each line is checked against the denomination master and carries the master's value: the amount
// of an exchange is never taken from a value the caller sent.
final Map<Long, Integer> masterValues = denominationValues();
final List<Leg> receivedLegs = parseLegs("received", received, masterValues);
final List<Leg> givenLegs = parseLegs("given", given, masterValues);
if (!hasPositiveCount(received) || !hasPositiveCount(given)) {
final BigDecimal receivedTotal = sumTotal(receivedLegs);
final BigDecimal givenTotal = sumTotal(givenLegs);
if (receivedLegs.isEmpty() || givenLegs.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
"Both received and given denominations are required.");
}
@@ -99,6 +118,19 @@ public class CashExchangeApiResource {
"Both received and given denominations are required.");
}
// PAN is checked whenever it is given, and required above Rs 50,000. The screen enforced the
// threshold on its own, through a free-text "PAN / Aadhaar" box, so an API call -- or any
// text in that box -- recorded a large exchange with no PAN behind it.
if (panNumber != null && !panNumber.matches(ClientKycApiConstants.PAN_REGEX)) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.invalid",
"PAN must be 5 letters, 4 digits and 1 letter (e.g. ABCDE1234F).", "panNumber", panNumber);
}
if (receivedTotal.compareTo(PAN_MANDATORY_ABOVE) > 0 && panNumber == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.required",
"Customer PAN is required for a cash exchange above Rs 50,000 (amount " + receivedTotal + ").", "panNumber",
receivedTotal);
}
CashExchange header = new CashExchange();
header.setAccountId(accountId);
header.setAccountNo(accountNo);
@@ -106,7 +138,12 @@ public class CashExchangeApiResource {
header.setExchangeAmount(receivedTotal);
header.setExchangeDate(exchangeDate);
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
// The Excess Transaction (CTR) report reads customer_id_proof, so the verified PAN fills it when
// nothing else was sent.
header.setCustomerIdProof(customerIdProof != null && !customerIdProof.isBlank() ? customerIdProof : panNumber);
header.setCustomerName(customerName);
header.setCustomerMobile(customerMobile);
header.setPanNumber(panNumber);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
@@ -114,91 +151,48 @@ public class CashExchangeApiResource {
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
// Auto-detect cashier linked to this user
try {
Long cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
header.setCashierId(cashierId);
} catch (Exception ignored) {
// intentionally ignored
}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
// who made the entry is recorded when it can be read
}
// Validate that cashier has enough of each denomination being GIVEN to the customer
if (header.getCashierId() != null) {
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
final java.util.List<String> shortages = new java.util.ArrayList<>();
for (JsonElement el : given) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
long denomId = item.get("denominationId").getAsLong();
long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
// An exchange takes notes into one cashier's drawer and hands change out of it, so only the
// cashier behind this login can record it, and it is always theirs. It used to go through with no
// cashier at all when the login was not a cashier's: the entry then belonged to no drawer, moved no
// cashier's position, and skipped the stock check below.
final Long cashierId = cashierRoleHelper.getCurrentCashierId();
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.cashier.required",
"A cash exchange can only be recorded by a cashier. Your login is not linked to a cashier.");
}
header.setCashierId(cashierId);
// The cashier has to hold, denomination by denomination, what they hand to the customer. A
// denomination is counted across all its lines (parseLegs merges them): checked line by line, two
// lines of 3 against a stock of 5 each passed although 6 were being given.
final Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(cashierId);
final List<String> shortages = new ArrayList<>();
for (final Leg leg : givenLegs) {
final long available = stock.getOrDefault(leg.denominationId(), 0L);
if (leg.count() > available) {
shortages.add("₹" + leg.value() + " (need " + leg.count() + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
}
CashExchange saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), "RECEIVED", received);
saveDetails(saved.getId(), "GIVEN", given);
saveDetails(saved.getId(), "RECEIVED", receivedLegs);
saveDetails(saved.getId(), "GIVEN", givenLegs);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
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)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
"Cash exchange " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("accountId") Long accountId,
@@ -238,52 +232,80 @@ public class CashExchangeApiResource {
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
/** One checked line of an exchange: a denomination from the master, the master's value for it, and how many. */
private record Leg(long denominationId, int value, int count) {
}
/** The value of every denomination in the master (a deleted one is gone; a discontinued one is still there). */
private Map<Long, Integer> denominationValues() {
final Map<Long, Integer> values = new HashMap<>();
for (final Map<String, Object> row : jdbcTemplate
.queryForList("SELECT id, denomination FROM m_denomination_master WHERE deleted = false")) {
values.put(((Number) row.get("id")).longValue(), ((Number) row.get("denomination")).intValue());
}
return values;
}
/**
* The lines of one side with a count, each checked against the denomination master and merged per
* denomination. A line must name a known denomination, and a value the caller sent for it has to be the
* master's: with the caller's value trusted, a request could pass a ₹10 note off as ₹500, make the two
* sides "equal", and shift a drawer's books by the difference. A discontinued denomination is accepted --
* a customer can still bring withdrawn notes to be exchanged.
*/
private List<Leg> parseLegs(final String side, final JsonArray items, final Map<Long, Integer> masterValues) {
final Map<Long, Leg> merged = new LinkedHashMap<>();
for (final JsonElement el : items) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.required",
"Every " + side + " line needs a denomination.");
}
final long id = item.get("denominationId").getAsLong();
final Integer value = masterValues.get(id);
if (value == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.unknown",
"Unknown denomination (id " + id + ") in the " + side + " notes.");
}
if (item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
&& item.get("denominationValue").getAsInt() != value) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.value.mismatch",
"Denomination " + id + " is ₹" + value + ", not ₹" + item.get("denominationValue").getAsInt() + ".");
}
merged.merge(id, new Leg(id, value, count), (a, b) -> new Leg(id, value, a.count() + b.count()));
}
return new ArrayList<>(merged.values());
}
private BigDecimal sumTotal(final List<Leg> legs) {
BigDecimal total = BigDecimal.ZERO;
for (final Leg leg : legs) {
total = total.add(BigDecimal.valueOf((long) leg.value() * leg.count()));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
private static String trimToNull(final JsonObject json, final String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return null;
}
return false;
final String value = json.get(key).getAsString().trim();
return value.isEmpty() ? null : value;
}
private void saveDetails(Long exchangeId, String direction, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashExchangeDetail detail = new CashExchangeDetail();
private void saveDetails(final Long exchangeId, final String direction, final List<Leg> legs) {
for (final Leg leg : legs) {
final CashExchangeDetail detail = new CashExchangeDetail();
detail.setExchangeId(exchangeId);
detail.setDirection(direction);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detail.setDenominationId(leg.denominationId());
detail.setDenominationValue(leg.value());
detail.setCount(leg.count());
detail.setTotalAmount(BigDecimal.valueOf((long) leg.value() * leg.count()));
detailRepository.save(detail);
}
}
@@ -296,6 +318,9 @@ public class CashExchangeApiResource {
map.put("accountNo", e.getAccountNo());
map.put("clientId", e.getClientId());
map.put("customerIdProof", e.getCustomerIdProof());
map.put("customerName", e.getCustomerName());
map.put("customerMobile", e.getCustomerMobile());
map.put("panNumber", e.getPanNumber());
map.put("exchangeAmount", e.getExchangeAmount());
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
map.put("note", e.getNote());
@@ -52,6 +52,17 @@ public class CashExchange {
@Column(name = "customer_id_proof", length = 50)
private String customerIdProof;
/** Walk-in customer's name; for an existing client, their display name at the time of the exchange. */
@Column(name = "customer_name", length = 150)
private String customerName;
@Column(name = "customer_mobile", length = 20)
private String customerMobile;
/** PAN verified at the counter; mandatory when the exchange is above Rs 50,000. */
@Column(name = "pan_number", length = 10)
private String panNumber;
@Column(name = "performed_by")
private Long performedBy;
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
);
// Globally unique voucher number from the central m_voucher register
@@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id
);
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
// means the cashier took the cash again, so the notes are recorded again -- without this an
// approved resubmission credited the account while the drawer never received its notes.
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
(String) record.get("account_no"), toLong(record.get("client_id")),
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
// Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -418,13 +429,26 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true);
} else {
// Determine real account type by checking which table owns this ID.
// Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix).
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
// The account_no is also checked -- m_current_account and m_savings_account each keep
// their own identity sequence, so the same numeric id can legitimately belong to a
// current account AND an unrelated savings account at once. An id match alone once sent
// a real deposit into a stranger's current account instead of the savings account the
// request actually named; account_no is the one value that can't collide between them.
final String requestAccountNo = (String) record.get("account_no");
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -571,7 +595,8 @@ public class CashierDepositRequestApiResource {
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
final String requestAccountNo = (String) record.get("account_no");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
}
@@ -666,6 +691,20 @@ public class CashierDepositRequestApiResource {
.orElse(Map.of());
}
/**
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
*/
private Long counterOfficeId(final AppUser user) {
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
: jdbcTemplate.queryForList("""
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.staff_id = ? LIMIT 1
""", Long.class, user.getStaff().getId());
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -688,7 +727,7 @@ public class CashierDepositRequestApiResource {
final Long savingsAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -718,6 +757,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -747,7 +844,7 @@ public class CashierDepositRequestApiResource {
return;
}
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -770,7 +867,7 @@ public class CashierDepositRequestApiResource {
final Long currentAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -798,17 +895,65 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
private boolean isCurrentAccountById(final Long accountId) {
/**
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
*
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
* this returned true, even though the request was for m_savings_account id=1.
*
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
* only trusted once the account_no on that current-account row confirms it is truly the same
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
* has no account_no to compare against, e.g. an id already known to be unambiguous).
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*
* expectedAccountNo guards the current-account check against an id collision with
* m_savings_account — see {@link #isCurrentAccountById}.
*/
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId, expectedAccountNo)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) {
@@ -831,7 +976,7 @@ public class CashierDepositRequestApiResource {
final Object paymentTypeId, final String txnDateStr, final String note,
final boolean isDeposit) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
@@ -884,9 +1029,14 @@ public class CashierDepositRequestApiResource {
*/
// Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
final String txnDate) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -917,10 +1067,15 @@ public class CashierDepositRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
""",
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// Dated on the request's own date, which is what the branch day's book figure reads.
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
// drawer on another and leave both days' cash wrong. No date falls back to the
// business date for the same reason.
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
cashierId, cashierUsername, paymentTypeName
);
}
@@ -1025,7 +1180,7 @@ public class CashierDepositRequestApiResource {
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
@@ -74,6 +74,15 @@ public class CashInterCashierApiResource {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
"From-cashier and To-cashier cannot be the same.");
}
// The cash leaves the sender's drawer, so only the sender can hand it over: the logged-in cashier
// is always the from-cashier. Nobody -- a manager, another cashier -- records a transfer out of
// somebody else's drawer.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(fromCashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.from.must.be.own",
"An inter-cashier transfer can only be made by the cashier handing the cash over, from their own"
+ " drawer.");
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
@@ -92,6 +101,13 @@ public class CashInterCashierApiResource {
"At least one denomination with a positive count is required.");
}
// Neither side can take part before the vault has allocated them cash for the day: a cashier
// who never opened their till has no morning allocation behind their drawer, so they can
// neither hand cash on nor take it in. Checked first so that is the message they see, not a
// shortfall on some denomination.
validateCashAllocated(fromCashierId, transferDate, "from");
validateCashAllocated(toCashierId, transferDate, "to");
// 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
@@ -134,45 +150,6 @@ public class CashInterCashierApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
CashInterCashier record = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
"Inter-cashier transfer not found: " + id));
if (Boolean.TRUE.equals(record.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
"Inter-cashier transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -209,6 +186,77 @@ public class CashInterCashierApiResource {
return gson.toJson(map);
}
/**
* One cashier's details, today's vault allocation and denomination-wise cash, so the inter-cashier
* screen can show both sides before the transfer is recorded. The Cashier Position endpoint cannot
* serve this: for a plain cashier it ignores {@code cashierId} and returns their own position, so
* the receiving cashier's figures would never show.
*/
@GET
@Path("cashier-summary")
public String cashierSummary(@QueryParam("cashierId") final Long cashierId, @QueryParam("date") final String date) {
context.authenticatedUser().validateHasReadPermission("CASHINTERCASHIER");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.required", "cashierId is required.");
}
final LocalDate asOf = date != null && !date.isBlank() ? LocalDate.parse(date) : DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id AS cashier_id, s.display_name AS staff_name, c.teller_id, t.name AS teller_name,
t.office_id, o.name AS office_name
FROM m_cashiers c
LEFT JOIN m_staff s ON s.id = c.staff_id
LEFT JOIN m_tellers t ON t.id = c.teller_id
LEFT JOIN m_office o ON o.id = t.office_id
WHERE c.id = ?
""", cashierId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.not.found",
"Cashier not found: " + cashierId);
}
final Map<String, Object> row = rows.get(0);
final BigDecimal allocated = allocatedAmount(cashierId, asOf);
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("cashierId", cashierId);
resp.put("cashierName", row.get("staff_name"));
resp.put("tellerId", row.get("teller_id"));
resp.put("tellerName", row.get("teller_name"));
resp.put("officeId", row.get("office_id"));
resp.put("officeName", row.get("office_name"));
resp.put("asOfDate", asOf.toString());
resp.put("cashAllocated", allocated.signum() > 0);
resp.put("allocatedAmount", allocated);
resp.put("totalAmount", position.get("totalAmount"));
resp.put("denominations", position.get("denominations"));
return gson.toJson(resp);
}
/**
* Cash the vault allocated to the cashier on the date, from the same non-reversed
* {@code m_cash_transfer} rows the "No Cash Allocated" gate on the transaction screens counts.
*/
private BigDecimal allocatedAmount(final Long cashierId, final LocalDate date) {
final BigDecimal amount = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)
AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
""", BigDecimal.class, cashierId, date.toString());
return amount != null ? amount : BigDecimal.ZERO;
}
private void validateCashAllocated(final Long cashierId, final LocalDate date, final String side) {
if (allocatedAmount(cashierId, date).signum() > 0) {
return;
}
final String role = "from".equals(side) ? "sending" : "receiving";
throw new PlatformDataIntegrityException("error.msg.cashintercashier." + side + ".cashier.cash.not.allocated",
"The " + role + " cashier has not been allocated cash from the vault for " + date
+ ". Allocate cash to them through Cash Transfer before an inter-cashier transfer.",
side + "CashierId", cashierId);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
@@ -231,8 +279,8 @@ public class CashInterCashierApiResource {
* 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.
* position means the cashier holds nothing: create() only gets here for a cashier the vault has
* allocated cash to, so an empty position is cash already handed on, not a setup state.
*/
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
final LocalDate transferDate) {
@@ -242,12 +290,9 @@ public class CashInterCashierApiResource {
@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) {
for (final Map<String, Object> row : heldRows == null ? List.<Map<String, Object>>of() : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -46,8 +47,26 @@ public class CashReconciliationApiResource {
private final CashReconciliationDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* A cashier reconciles only their own drawer. The cashier is picked on the screen, and two cashiers
* under one teller could name each other: one cashier's count then stands in for another's, and the
* reconciliation record is what a settlement is later allowed on the strength of. A user who is not
* a cashier-only login (a manager, the branch head) is not limited.
*/
private void requireOwnCashier(final Long cashierId) {
if (!cashierRoleHelper.isCashierOnly()) {
return;
}
final Long own = cashierRoleHelper.getCurrentCashierId();
if (own == null || !own.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.other.cashier",
"A cashier can only reconcile their own cash.");
}
}
/**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier.
@@ -64,6 +83,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
@@ -90,10 +110,10 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "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 = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Out: settlements to vault
@@ -120,10 +140,10 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "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 = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
@@ -227,6 +247,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
@@ -250,6 +271,14 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId is required.");
}
// A reconciliation is the cashier's own count of their own drawer. Unlike reading one (a manager
// looks at a cashier's, and day close checks each), recording one is the cashier's alone: neither
// another cashier nor a manager can count a drawer on its owner's behalf.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.own.cashier.only",
"A reconciliation can only be recorded by the cashier whose drawer it is.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
@@ -338,7 +367,11 @@ public class CashReconciliationApiResource {
@GET
public String getAll() {
// A cashier sees their own reconciliations only; everyone else sees all of them.
final boolean cashierOnly = cashierRoleHelper.isCashierOnly();
final Long ownCashierId = cashierOnly ? cashierRoleHelper.getCurrentCashierId() : null;
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.filter(e -> !cashierOnly || (ownCashierId != null && ownCashierId.equals(e.getCashierId())))
.map(this::toMap).collect(Collectors.toList()));
}
@@ -347,6 +380,7 @@ public class CashReconciliationApiResource {
public String getOne(@PathParam("id") Long id) {
CashReconciliation header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
requireOwnCashier(header.getCashierId());
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
@@ -14,11 +14,13 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -35,6 +37,7 @@ public class CashReportApiResource {
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final VoucherService voucherService;
private final Gson gson = new Gson();
/**
@@ -60,34 +63,11 @@ public class CashReportApiResource {
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* shared with the day register, the reconciliation screen and the withdrawal stock check, so a
* drawer reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// 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.
//
// 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
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
/**
* Day book — every voucher raised on one date, in one list, newest first.
@@ -106,7 +86,7 @@ public class CashReportApiResource {
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
final List<Map<String, Object>> rows = new ArrayList<>();
@@ -351,7 +331,18 @@ public class CashReportApiResource {
BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO;
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) {
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
@@ -372,7 +363,7 @@ public class CashReportApiResource {
// was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
@@ -493,7 +484,7 @@ public class CashReportApiResource {
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
@@ -694,25 +685,25 @@ public class CashReportApiResource {
// day itself: taken from the vault, in over the counter, out over the counter, settled
// back. What is left is what should physically be in the drawer.
result.put("todayVaultIn", dayLegTotal(
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ct.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todaySettledOut", dayLegTotal(
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
+ " AND COALESCE(cs.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierIn", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierOut", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
@@ -744,7 +735,7 @@ public class CashReportApiResource {
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "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 = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
@@ -961,7 +952,7 @@ public class CashReportApiResource {
return gson.toJson(result);
}
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String today = DateUtils.getBusinessLocalDate().toString();
// Find the earliest transfer date before today for which no settlement exists on or after that date
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
@@ -1024,7 +1015,7 @@ public class CashReportApiResource {
@Path("cashier-position")
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
// CASHIER role: always scope to their own position only
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
@@ -1055,7 +1046,7 @@ public class CashReportApiResource {
@GET
@Path("cashier-position/me")
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
@@ -1093,7 +1084,7 @@ public class CashReportApiResource {
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
@QueryParam("asOfDate") final String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
: DateUtils.getBusinessLocalDate().toString();
if (tellerId == null) {
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
@@ -1106,6 +1097,137 @@ public class CashReportApiResource {
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
* GET /cashreports/branch-position?officeId=X&asOfDate=yyyy-MM-dd
*
* The notes and coins a branch holds, denomination by denomination: its vault(s) plus every cashier's
* drawer. Cash moving between the vault and a drawer, or from one drawer to another, nets out in this
* total; what changes it is what crosses the counter -- a deposit, a withdrawal, an exchange. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) is +10 on ₹500 and -50 on ₹100 here, and the
* "exchangeNet" column carries that day's exchange movement beside the total so it can be seen.
*
* Until now no screen showed this total: the vault is untouched by an exchange and each drawer is shown
* alone, so what the branch held per denomination was only known from the hand-entered count at day
* open and close. Not for a cashier -- it spans every drawer; they see their own on Cashier Position.
*/
@GET
@Path("branch-position")
public String branchPosition(@QueryParam("officeId") final Long officeIdParam, @QueryParam("asOfDate") final String asOfDate) {
if (cashierRoleHelper.isCashierOnly()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.not.for.cashier",
"The branch cash position covers every drawer; a cashier sees their own on the Cashier Position screen.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: DateUtils.getBusinessLocalDate().toString();
final Long officeId = officeIdParam != null ? officeIdParam : context.authenticatedUser().getOffice().getId();
final List<String> officeNames = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
if (officeNames.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.office.not.found",
"Office not found: " + officeId);
}
// One row per denomination in the master; a discontinued one (the ₹2000 note) shows only while
// something is still held or moved in it.
final Map<Long, Map<String, Object>> rows = new LinkedHashMap<>();
for (final Map<String, Object> m : jdbcTemplate
.queryForList("SELECT id, denomination, kind, is_active FROM m_denomination_master WHERE deleted = false")) {
final long id = ((Number) m.get("id")).longValue();
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", id);
row.put("denominationValue", ((Number) m.get("denomination")).intValue());
row.put("kind", m.get("kind"));
row.put("active", Boolean.TRUE.equals(m.get("is_active")));
row.put("vaultCount", 0L);
row.put("cashierCount", 0L);
row.put("exchangeNet", 0L);
rows.put(id, row);
}
boolean vaultHasBreakdown = false;
for (final Long tellerId : jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ? ORDER BY id", Long.class,
officeId)) {
final Map<String, Object> vault = vaultPositionService.buildPositionForVault(tellerId, dateFilter);
vaultHasBreakdown = vaultHasBreakdown || Boolean.TRUE.equals(vault.get("hasBreakdown"));
addPositionCounts(rows, vault, "vaultCount");
}
for (final Long cashierId : jdbcTemplate.queryForList(
"SELECT mc.id FROM m_cashiers mc JOIN m_tellers t ON t.id = mc.teller_id WHERE t.office_id = ? ORDER BY mc.id",
Long.class, officeId)) {
addPositionCounts(rows, buildPositionForCashier(cashierId, dateFilter), "cashierCount");
}
// The day's exchanges across the branch's cashiers: notes taken in less notes given out, per denomination.
for (final Map<String, Object> r : jdbcTemplate.queryForList("""
SELECT ced.denomination_id,
SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE -ced.count END) AS net
FROM m_cash_exchange_detail ced
JOIN m_cash_exchange ce ON ce.id = ced.exchange_id
JOIN m_cashiers mc ON mc.id = ce.cashier_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE t.office_id = ? AND ce.exchange_date = CAST(? AS DATE)
AND (ce.is_reversed IS NULL OR ce.is_reversed = false)
GROUP BY ced.denomination_id
""", officeId, dateFilter)) {
final Object id = r.get("denomination_id");
final Object net = r.get("net");
if (id instanceof Number && net instanceof Number && rows.containsKey(((Number) id).longValue())) {
rows.get(((Number) id).longValue()).put("exchangeNet", ((Number) net).longValue());
}
}
final List<Map<String, Object>> lines = new ArrayList<>();
BigDecimal vaultAmount = BigDecimal.ZERO;
BigDecimal cashiersAmount = BigDecimal.ZERO;
for (final Map<String, Object> row : rows.values()) {
final long vaultCount = ((Number) row.get("vaultCount")).longValue();
final long cashierCount = ((Number) row.get("cashierCount")).longValue();
final long exchangeNet = ((Number) row.get("exchangeNet")).longValue();
final int value = ((Number) row.get("denominationValue")).intValue();
if (!Boolean.TRUE.equals(row.get("active")) && vaultCount == 0 && cashierCount == 0 && exchangeNet == 0) {
continue;
}
row.put("totalCount", vaultCount + cashierCount);
row.put("totalAmount", BigDecimal.valueOf(value * (vaultCount + cashierCount)));
vaultAmount = vaultAmount.add(BigDecimal.valueOf(value * vaultCount));
cashiersAmount = cashiersAmount.add(BigDecimal.valueOf(value * cashierCount));
lines.add(row);
}
lines.sort((a, b) -> Integer.compare(((Number) b.get("denominationValue")).intValue(),
((Number) a.get("denominationValue")).intValue()));
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("officeId", officeId);
resp.put("officeName", officeNames.get(0));
resp.put("asOfDate", dateFilter);
resp.put("vaultHasBreakdown", vaultHasBreakdown);
resp.put("vaultAmount", vaultAmount);
resp.put("cashiersAmount", cashiersAmount);
resp.put("totalAmount", vaultAmount.add(cashiersAmount));
resp.put("denominations", lines);
return gson.toJson(resp);
}
/** Adds a position's per-denomination counts (a vault's or a cashier's) into one field of the rows. */
@SuppressWarnings("unchecked")
private void addPositionCounts(final Map<Long, Map<String, Object>> rows, final Map<String, Object> position,
final String field) {
final List<Map<String, Object>> lines = (List<Map<String, Object>>) position.get("denominations");
if (lines == null) {
return;
}
for (final Map<String, Object> line : lines) {
final Object id = line.get("denominationId");
final Object count = line.get("count");
if (!(id instanceof Number) || !(count instanceof Number)) {
continue;
}
final Map<String, Object> row = rows.get(((Number) id).longValue());
if (row != null) {
row.merge(field, ((Number) count).longValue(), (a, b) -> ((Number) a).longValue() + ((Number) b).longValue());
}
}
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -1115,7 +1237,7 @@ public class CashReportApiResource {
@Path("cashier-morning")
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
if (cashierId == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
@@ -1194,7 +1316,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "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 = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
@@ -1220,7 +1342,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "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 = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -1327,7 +1449,7 @@ public class CashReportApiResource {
@QueryParam("offset") Integer offset,
@QueryParam("limit") Integer limit) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
@@ -1338,13 +1460,13 @@ public class CashReportApiResource {
if (cashierId != null) {
officerFilter = "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 = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, cashierId };
} else if (tellerId != null) {
officerFilter = "AND std.officer_id IN ("
+ "SELECT au.id FROM m_appuser au "
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, tellerId };
} else {
officerFilter = "AND std.officer_id IN ("
@@ -33,7 +33,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
@@ -89,7 +88,7 @@ public class CashSettlementApiResource {
}
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
@@ -181,64 +180,6 @@ public class CashSettlementApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
CashSettlement settlement = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
"Settlement not found: " + id));
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
"Settlement " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
try {
Long glTellerId = settlement.getToTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.allocateCashToCashier(glTellerId,
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
@Path("check")
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
@@ -247,7 +188,7 @@ public class CashSettlementApiResource {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
"cashierId query param is required.");
}
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
Integer.class, cashierId, settlementDate);
@@ -35,7 +35,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -58,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -103,6 +116,9 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -113,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -146,7 +156,7 @@ public class CashTransferApiResource {
} catch (Exception ignored) {
// intentionally ignored
}
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
@@ -160,65 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
CashTransfer transfer = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
"Cash transfer not found: " + id));
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
"Cash transfer " + id + " is already reversed.");
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
transfer.setReverseReason(reason);
repository.save(transfer);
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
try {
Long glTellerId = transfer.getFromTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.settleCashFromCashier(glTellerId,
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> params = new java.util.ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND maker_id = ?");
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> row : rows) {
// The notes asked for, so the approver sees what they are releasing without opening anything.
final Object requestBody = row.remove("request_body");
row.put("denominations", requestBody == null ? new JsonArray()
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
}
return gson.toJson(rows);
}
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
@POST
@Path("requests/{id}/approve")
public String approveRequest(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "approved");
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
if (makerId != null && makerId.equals(user.getId())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
}
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
final int updated = jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
WHERE id = ? AND status = ?
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
transferId, id, REQUEST_PENDING);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
"This request was already actioned by another manager.");
}
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_APPROVED);
if (transferId != null) {
result.addProperty("cashTransferId", transferId);
}
if (postedJson.has("glWarning")) {
result.add("glWarning", postedJson.get("glWarning"));
}
return gson.toJson(result);
}
@POST
@Path("requests/{id}/reject")
public String rejectRequest(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "rejected");
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final String reason = textOrNull(json, "reason");
if (reason == null || reason.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
}
jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
WHERE id = ? AND status = ?
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
reason.trim(), id, REQUEST_PENDING);
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_REJECTED);
return gson.toJson(result);
}
private static final String REQUEST_PENDING = "PENDING";
private static final String REQUEST_APPROVED = "APPROVED";
private static final String REQUEST_REJECTED = "REJECTED";
private Map<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
}
return rows.get(0);
}
private void requirePending(final Map<String, Object> request, final String action) {
if (!REQUEST_PENDING.equals(request.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
}
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void requireChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
}
}
private static String textOrNull(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -291,7 +407,7 @@ public class CashTransferApiResource {
"Vault/teller " + fromTellerId + " was not found.");
}
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
final List<Long> registerIds = jdbcTemplate.query("""

Some files were not shown because too many files have changed in this diff Show More