Compare commits

...
10 Commits
Author SHA1 Message Date
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
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
26 changed files with 1001 additions and 282 deletions
@@ -4809,4 +4809,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;
}
}
@@ -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);
}
@@ -651,6 +651,8 @@ public class AccountingProcessorHelper {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId);
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
// where a bank actually picks it. The reserved name in the product's other GL mappings is
@@ -664,13 +666,20 @@ public class AccountingProcessorHelper {
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
.orElse(payableAccount);
// The interest expense is not raised here. It is recognised as the interest accrues, day by
// day, and this posting settles that recognised debt - raising the expense again at payout
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
// which pays a product's whole membership the same way; a closure reaching this path must
// produce the same entries as the nightly run, or the two disagree on what one payment did.
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
// named Payable account if the product has one, or straight against Savings Control if it
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
// entries for interest until this leg existed, since a product with no Payable/Paid names
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
// to post in their place.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// The interest expense is not raised again here. It was just raised above, and this posting
// only settles that recognised debt onward to where it is actually paid out from -- raising
// the expense a second time would put the same interest on the P&L twice.
//
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
@@ -679,7 +688,7 @@ public class AccountingProcessorHelper {
isReversal);
}
// Stage 2: into the member's own balance.
// Stage 3: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
@@ -105,17 +105,22 @@ 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=2 (PortfolioProductType.SAVING) is written for BOTH savings and
// current-account entries -- current products deliberately share SAVING's GL type
// space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two
// apart on its own, and m_savings_account/m_current_account each have their own id
// sequence: 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 here -- only a real savings transaction sets it -- so
// it decides first; only when it's absent (a current-account entry, or a savings
// entry with no linked transaction row) does the current-account guess apply.
// 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 ")
@@ -141,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 ")
@@ -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();
@@ -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
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
@@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService {
}
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
accountId, accountType);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
@@ -346,7 +347,8 @@ public class ChequeBookIssuanceChargeService {
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) {
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
final ChequeAccountType accountType) {
final JsonArray debitArr = new JsonArray();
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", debitGlId);
@@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService {
// link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do.
final String glTxnId = result.getTransactionId();
if (glTxnId != null) {
linkDebitEntryToAccount(glTxnId, accountId);
linkDebitEntryToAccount(glTxnId, accountId, accountType);
}
}
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an
* Account No.
*
* 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 -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of
* accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings
* account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually
* means "this id is in m_current_account" -- use it whenever accountType says so.
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = accountType == ChequeAccountType.CURRENT
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -118,7 +119,7 @@ public class ClearingJournalService {
}
final String glTxnId = result.getTransactionId();
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
return glTxnId;
@@ -191,7 +192,13 @@ public class ClearingJournalService {
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
/**
* Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two
* account tables number independently, so entity_type_enum must say which one accountId names, not
* assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means
* "this id is in m_current_account".
*/
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
@@ -200,8 +207,11 @@ public class ClearingJournalService {
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
rowIds.get(0));
final int entityType = "CURRENT".equalsIgnoreCase(accountType)
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType,
accountId, rowIds.get(0));
}
}
}
@@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
@@ -568,7 +569,7 @@ public class ClerkDisbursementRequestApiResource {
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
@@ -577,7 +578,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
}
@@ -681,7 +682,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true));
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else {
// Real CBS book transfer: direct credit to savings account.
@@ -696,7 +697,7 @@ public class ClerkDisbursementRequestApiResource {
accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false));
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
} else {
@@ -715,7 +716,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(debitTxnId));
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount));
if (isCurrentAccount) {
undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId));
} else {
@@ -979,7 +980,7 @@ public class ClerkDisbursementRequestApiResource {
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else {
@@ -1005,7 +1006,7 @@ public class ClerkDisbursementRequestApiResource {
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else {
@@ -1568,20 +1569,27 @@ public class ClerkDisbursementRequestApiResource {
final BigDecimal amount;
final String accountRef; // savings account number shown in GL comments; null for source/target GL
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) {
this(glAccountId, amount, accountRef, txnId, null);
this(glAccountId, amount, accountRef, txnId, null, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId, final Long savingsAccountId) {
this(glAccountId, amount, accountRef, txnId, savingsAccountId, false);
}
GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId,
final Long savingsAccountId, final boolean isCurrentAccount) {
this.glAccountId = glAccountId;
this.amount = amount;
this.accountRef = accountRef;
this.txnId = txnId;
this.savingsAccountId = savingsAccountId;
this.isCurrentAccount = isCurrentAccount;
}
}
@@ -1603,8 +1611,16 @@ public class ClerkDisbursementRequestApiResource {
/**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
* row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—".
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
*
* 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 -- this used to tag every entry entity_type_enum=2
* (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view
* would resolve a current account's entry to a same-numbered savings account or vice versa.
* entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's
* entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one
* this entry's id actually is, decided once already, where the account was first resolved.
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
@@ -1618,11 +1634,15 @@ public class ClerkDisbursementRequestApiResource {
Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
final GlEntry entry = entries.get(i);
final Long savingsAccId = entry.savingsAccountId;
if (savingsAccId != null) {
final int entityType = entry.isCurrentAccount
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
savingsAccId, rowIds.get(i));
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?",
entityType, savingsAccId, rowIds.get(i));
}
}
}
@@ -33,8 +33,12 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder
* needs (account/client/product identity, maturity date, deposit and maturity amounts).
* FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the
* real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no
* closure instruction to auto-process -- with just the fields the maturity ladder needs
* (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays
* here until it's actually renewed or closed, since that's exactly the "needs manual attention"
* state the ladder exists to surface.
*
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
@@ -57,6 +61,8 @@ public class FixedDepositMaturityDashboardApiResource {
private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300;
/** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */
private static final int MATURED = 800;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@@ -87,9 +93,9 @@ public class FixedDepositMaturityDashboardApiResource {
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ?
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?)
ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE);
""", FIXED_DEPOSIT, ACTIVE, MATURED);
return this.gson.toJson(rows);
}
@@ -0,0 +1,60 @@
/**
* 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.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms
* anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current
* balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the
* balance the new term will actually start from -- exactly the numbers
* {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the
* account, so this only needs read permission, unlike its posting sibling.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.")
@RequiredArgsConstructor
public class FixedDepositRenewalPreviewApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("accountId") final Long accountId) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final Map<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
return this.gson.toJson(result);
}
}
@@ -303,16 +303,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest()) {
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
// the deposit rolls over -- Reinvest Principal Only renews just the closing principal here
// (below), with only the leftover interest actually leaving, rather than the legacy
// close-and-reopen this branch used to use, which left the customer with a second account
// number for something that renews here just as cleanly as Reinvest Principal + Interest
// does. Full reinvest itself still moves nothing: the whole matured balance simply becomes
// the new term's principal, so there is no withdrawal, no deposit and no journal entry for
// the rollover itself.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance;
// Whatever doesn't renew is interest the customer earned and has to actually be paid out
// somewhere -- validated before anything below runs, so a missing target fails cleanly with
// nothing done rather than leaving a half-completed reinvestment.
final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount);
if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
throw new GeneralPlatformDomainRuleException(
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
}
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
@@ -337,66 +350,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
// Whatever is left after the principal moves into the new deposit is interest the customer
// earned and has to actually be paid out somewhere -- it must not just sit on this account,
// which is about to be closed. Validated before anything below runs, so a missing target
// fails cleanly with nothing done rather than leaving a half-completed reinvestment.
final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount);
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
throw new GeneralPlatformDomainRuleException(
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
}
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
// Reached from the maturity job processing the account's standing maturity instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
// Pay out whatever didn't move into the new deposit, then actually close this account --
// previously this branch left the leftover interest stranded on an account that was never
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
// Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is
// zero (renewedAmount already equals maturedBalance), so this stays a no-op for it.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -409,8 +376,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
@@ -751,13 +716,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
// Only on this first-ever close of this term -- otherwise a term that renews more than once
// (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the
// same chart against the same closingRow every time, piling up duplicate slab rows for a
// chart that only ever needs archiving once.
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
@@ -884,9 +852,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and
* Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just
* Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with
* only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}
* this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's
* own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an
* already-stored instruction).
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
@@ -953,10 +925,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// These three branches keep the account open, so they never reach close()/prematureClosure()
// and would otherwise accept any date at all -- including one behind already-posted interest,
// which the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
@@ -972,6 +944,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Exactly the closing term's own principal -- the fixed point on the same split Partial
// Reinvest lets staff pick freely. Renews in place same as the other two, on just this
// account, with only the interest actually leaving (below) -- no second account, unlike the
// legacy close-and-reopen this branch used to do.
renewedAmount = closingPrincipal;
} else {
renewedAmount = maturedBalance;
}
@@ -1004,74 +982,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
// term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is
// an actual money movement, posted the same way the Transfer to Savings branch below posts
// its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
}
} else if (onClosureType.isTransferToSavings()) {
@@ -1008,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
// Reinvest Principal Only now renews in place on this same account (see
// DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually
// set any more, but this guard is left as a harmless no-op rather than removed, in case a future
// closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
@@ -1554,7 +1555,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// an account opened straight into Reinvest Principal Only can reach here with none set.
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId();
toSavingsId = resolveLinkedAccountId(fdAccount.getId());
}
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
@@ -1566,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
// Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed,
// in case a future closure type reintroduces a genuine close-and-reopen.
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
@@ -1620,7 +1618,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
// should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId();
final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId());
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
@@ -1630,13 +1628,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
@@ -1669,9 +1661,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId);
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that
// actually carries the new term's maturity date/amount either way.
// Both reinvest types now renew this same account in place (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never
// actually set any more, but this guard is left as a harmless no-op rather than removed, in case
// a future closure type reintroduces a genuine close-and-reopen.
FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
@@ -1697,17 +1690,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
"This FD has not matured yet.");
}
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount)));
final Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace);
@@ -1716,49 +1701,160 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
/**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
* linked Savings/Current account's product -- credited onto the FD itself as a genuine
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD
* Dashboard's generic Payment command) reads the account's balance. Shared by
* {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's
* Renew action now calls the latter as a pre-step before submitting the same generic `payment`
* command the account's own Account Actions menu already uses, so every closure type (Transfer to
* Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the
* two this method's sibling endpoint originally supported.
* Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment
* dialog before the staff member confirms anything -- the actual current balance, the linked
* account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid
* onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so
* projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews.
* Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets
* zero for the gap fields since nothing is owed.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = fdAccount.getLinkedAccountId();
if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it "
+ "before renewing late, so the gap-period interest has a rate to use.");
@Transactional(readOnly = true)
@Override
public Map<String, Object> previewFdRenewal(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
final Map<String, Object> result = new HashMap<>();
if (!fdAccount.isMatured()) {
result.put("isMatured", false);
return result;
}
final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject("""
result.put("isMatured", true);
final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount);
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
final BigDecimal currentBalance = fdAccount.getAccountBalance();
result.put("maturityDate", maturityDate.toString());
result.put("renewalGracePeriodDays", renewalGracePeriodDays);
result.put("withinGrace", withinGrace);
result.put("currentBalance", currentBalance);
if (withinGrace) {
result.put("gapDays", 0L);
result.put("gapInterestAmount", BigDecimal.ZERO);
result.put("projectedBalance", currentBalance);
return result;
}
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
result.put("gapDays", gapDays);
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
result.put("linkedAccountId", linkedAccountId);
if (linkedAccountId == null) {
// No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking
// "link a Savings account first" message rather than a number, same as the posting path does.
result.put("gapInterestAmount", null);
result.put("projectedBalance", null);
return result;
}
result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId));
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
result.put("linkedAccountRate", linkedAccountRate);
result.put("gapInterestAmount", gapInterestAmount);
// Paid onto the linked savings account, not this FD -- what renews is still just the FD's own
// current balance.
result.put("projectedBalance", currentBalance);
return result;
}
/** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */
private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) {
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays;
}
/**
* The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's
* {@code getLinkedAccountId()} -- an account whose link was set at creation through the real
* account-opening command reads back correctly there, but this call site kept returning null for
* an account confirmed to have {@code linked_account_id} set in the database, which every one of
* this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps
* whatever staleness that entity accessor has, rather than trying to chase it down across every
* caller that touches this account within the same request.
*/
private Long resolveLinkedAccountId(final Long savingsAccountId) {
return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
}
private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) {
return this.jdbcTemplate.queryForObject("""
SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", BigDecimal.class, linkedAccountId);
}
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
/** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} --
* shown in the Renewal Preview so staff can see exactly which account the gap interest is about
* to land on, not just that "a linked account" exists. */
private String resolveLinkedAccountNo(final Long linkedAccountId) {
return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId);
}
/** Simple interest on the FD's own current balance, for the stretch between its maturity date and
* today, at the linked account's product rate -- the pure calculation, shared by the posting path
* ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number
* shown to staff before they confirm is exactly the number that gets credited. */
private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate,
final long gapDays) {
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
: fdAccount.getInterestCalculationDaysInYearType();
final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc)
.multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc)
.setScale(2, java.math.RoundingMode.HALF_UP);
return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc)
.divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP);
}
/**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
* linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine
* INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in
* the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at
* the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding
* it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings
* interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay
* it out through the FD's own closure math instead of it already being where it belongs. The FD's
* own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or
* the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap
* interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit}
* and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as
* a pre-step before submitting the same generic `payment` command the account's own Account Actions
* menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial
* Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint
* originally supported.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId());
if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it "
+ "before renewing late, so the gap-period interest has somewhere to be paid.");
}
final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT);
final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId);
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays);
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(),
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true);
fdAccount.addTransaction(gapInterestTxn);
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount,
linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true);
linkedAccount.addTransaction(gapInterestTxn);
linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount);
postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds);
}
return gapInterestAmount;
}
@@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
msg = "Savings product with short name `" + shortName + "` already exists";
param = "shortName";
msgArgs = new Object[] { shortName, dae };
} else if (checkEx.getMessage().contains("numeric field overflow")) {
msgCode += ".amount.too.large";
msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again.";
msgArgs = new Object[] { dae };
} else {
msgCode += ".unknown.data.integrity.issue";
msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage();
msgArgs = new Object[] { dae };
}
log.error("Error occured.", dae);
@@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
@@ -602,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final Long drGlAccountId;
Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null;
final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null;
@@ -627,6 +629,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
}
if (drGlAccountId == null) {
throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping.");
@@ -662,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount,
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
linkedAccountIsCurrent);
final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft(
leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName,
@@ -912,6 +916,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
Long resultingCurrentTransactionId = null;
final Long drGlAccountId;
Long linkedAccountId = null;
boolean linkedAccountIsCurrent = false;
if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) {
final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
// Book debit, no journal entry of its own -- see approveIssueRequest().
@@ -925,6 +930,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, currentUser.getId(), note);
drGlAccountId = getCurrentControlGlId(currentAccountId);
linkedAccountId = currentAccountId;
linkedAccountIsCurrent = true;
}
if (drGlAccountId == null) {
throw new IllegalStateException(
@@ -946,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary;
final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges,
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId);
chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId,
linkedAccountIsCurrent);
for (final Map<String, Object> request : requests) {
this.sponsorDDWritePlatformService.issueDemandDraft(
@@ -1757,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* liability, so they get their own credit leg rather than being folded into the liability). Resolves
* the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request /
* m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's
* savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 /
* entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching
* how Fineract's own auto-generated entries display.
* savings/current account for an account-debit DD), the Dr-side row is tagged
* entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so
* GL reports show the account number instead of a blank "—", matching how Fineract's own
* auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account
* each with their own sequence) can't resolve a same-numbered stranger's account instead.
*/
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate,
final String comments, final Long linkedAccountId) {
return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId,
txnDate, comments, linkedAccountId);
txnDate, comments, linkedAccountId, false);
}
/**
@@ -1811,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) {
final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId,
final boolean linkedAccountIsCurrent) {
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0;
@@ -1872,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
if (linkedAccountId != null) {
// 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 -- entity_type_enum must say which one linkedAccountId
// names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit
// branch resolves either a savingsAccountId or a currentAccountId into this same parameter).
final int entityType = linkedAccountIsCurrent
? PortfolioProductType.CURRENT.getValue()
: PortfolioProductType.SAVING.getValue();
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
"UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ("
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId);
entityType, linkedAccountId, transactionId);
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
@@ -18,7 +18,6 @@
*/
package org.apache.fineract.spm.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import lombok.RequiredArgsConstructor;
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.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
@@ -57,6 +60,8 @@ public class SpmApiResource {
private final PlatformSecurityContext securityContext;
private final SpmService spmService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -103,12 +108,10 @@ public class SpmApiResource {
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return getResponse(survey.getId());
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@PUT
@@ -116,12 +119,10 @@ public class SpmApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Transactional
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey surveyToUpdate = this.spmService.findById(id);
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return getResponse(survey.getId());
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@@ -131,22 +132,17 @@ public class SpmApiResource {
@Transactional
@Operation(summary = "Deactivate Survey", description = "")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.securityContext.authenticatedUser();
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
final CommandWrapper commandRequest;
if (command != null && command.equalsIgnoreCase("activate")) {
this.spmService.activateSurvey(id);
commandRequest = builder.activateSurvey(id).build();
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
this.spmService.deactivateSurvey(id);
commandRequest = builder.deactivateSurvey(id).build();
} else {
throw new UnrecognizedQueryParamException("command", command);
}
}
private String getResponse(Long id) {
Gson gson = new Gson();
HashMap<String, Object> response = new HashMap<>();
response.put("resourceId", id);
return gson.toJson(response);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,53 @@
/**
* 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.spm.handler;
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.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "ACTIVATE")
public class ActivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public ActivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.activateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,71 @@
/**
* 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.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
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.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "CREATE")
public class CreateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -0,0 +1,53 @@
/**
* 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.spm.handler;
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.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "DEACTIVATE")
public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public DeactivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.deactivateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,72 @@
/**
* 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.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
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.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "UPDATE")
public class UpdateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey surveyToUpdate = this.spmService.findById(command.entityId());
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -114,17 +114,33 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
/*
* Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline
* (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a
* different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen
* (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global
* maker-checker on/off switch and other System -> Configurations settings. If either were flipped
* to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin
* could lock themselves out of ever approving further changes to maker-checker configuration
* itself, with no ordinary way to reverse it. Excluding them here only hides them from this
* screen; it does not touch their can_maker_checker value or their normal enforcement.
*/
private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'";
public String makerCheckerablePermissionSchema() {
/*
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above).
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as
* codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES
* above) to avoid a self-lockout.
*/
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
+ " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")"
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
}
@@ -149,4 +149,7 @@
dev-latest's highest, 0582) to avoid ambiguity; content/semantics unchanged, only the file
name and this changeset's id were updated to match. -->
<include file="parts/0583_add_loan_originator_checker_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The Manage Surveys screen (SPM scorecards, spm.api.SpmApiResource) previously performed
create/update/activate/deactivate directly against SpmService with only a login check -
no permission check at any layer. These 4 write operations have now been wired into the
standard command pipeline (CommandWrapperBuilder.createSurvey/updateSurvey/activateSurvey/
deactivateSurvey -> CreateSurveyCommandHandler/UpdateSurveyCommandHandler/
ActivateSurveyCommandHandler/DeactivateSurveyCommandHandler, entity "SURVEY"), which requires
these permission rows to exist so PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource()
can authorize the caller. Seeded with can_maker_checker=false: no prior maker-checker behaviour
existed for this screen, and enabling maker-checker is a business decision left for later. -->
<changeSet id="0584_add_manage_survey_permissions" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="CREATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="UPDATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="ACTIVATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="ACTIVATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="DEACTIVATE_SURVEY"/>
<column name="entity_name" value="SURVEY"/>
<column name="action_name" value="DEACTIVATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- No role previously held any permission for this screen (it had no permission check at all),
so there are no "roles that already hold the base permission" to copy from. Following the
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
Super user plus any manager-named role. -->
<changeSet id="0584_grant_manage_survey_permissions" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY')
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- LoanCOBCatchUpApiResource.executeLoanCOBCatchUp() (POST /v1/loans/catch-up) previously had no
permission check at all. A validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP") check was added
directly (not full CommandWrapper/maker-checker wiring, since this is a job-execution trigger,
not a CRUD operation on a persisted entity - matching the existing EXECUTE_INLINE_JOB precedent),
which requires this permission row to exist. can_maker_checker=false: this closes the
authorization gap only, it does not introduce maker-checker for job execution. -->
<changeSet id="0585_add_loan_cob_catchup_permission" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="EXECUTE_LOANCOBCATCHUP"/>
<column name="entity_name" value="LOANCOBCATCHUP"/>
<column name="action_name" value="EXECUTE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- No role previously held any permission for this endpoint (it had no permission check at all),
so there are no "roles that already hold the base permission" to copy from. Following the
0511_institution_and_branch_profile.xml convention for a brand-new permission model: grant to
Super user plus any manager-named role. -->
<changeSet id="0585_grant_loan_cob_catchup_permission" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP'
WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = r.id AND rp2.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The Admin -> System maker-checker audit found 8 base permissions whose "_CHECKER" counterpart
row was never seeded (AppUser.validateHasCheckerPermissionTo() computes
checkerPermissionName = code.toUpperCase() + "_CHECKER" and requires the approver to hold
either CHECKER_SUPER_USER or that exact permission code - see the identical issue already
fixed for LOAN_ORIGINATOR in 0583_add_loan_originator_checker_permissions.xml). Without these
rows, no ordinary role can ever be granted checker rights for these operations, and
UPDATE_BATCH_BUSINESS_STEP (Manage Jobs - COB Workflow Step Reorder) is the one permission in
all of Admin -> System that already has can_maker_checker=true today, making its missing
checker row a live gap, not merely a structural one. All 8 rows are seeded with
can_maker_checker=false: this changeset only makes the approval path structurally ready, it
does not itself enable maker-checker on any base permission.
For CREATE/UPDATE/DELETE_ACCOUNTNUMBERFORMAT_CHECKER, entity_name is set to
"ACCOUNTNUMBERFORMAT" for all three (matching CREATE_ACCOUNTNUMBERFORMAT and
UPDATE_ACCOUNTNUMBERFORMAT). The pre-existing base permission DELETE_ACCOUNTNUMBERFORMAT has
entity_name="HOOK" (a pre-existing data bug, confirmed via read-only query, left untouched -
fixing an existing permission row's value was out of scope for this change). This checker row
deliberately uses the correct entity name rather than propagating that bug. -->
<changeSet id="0586_add_system_module_checker_permissions" author="cbs-anshul">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="CREATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="UPDATE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="configuration"/>
<column name="code" value="DELETE_ACCOUNTNUMBERFORMAT_CHECKER"/>
<column name="entity_name" value="ACCOUNTNUMBERFORMAT"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="CREATE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="UPDATE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="DELETE_ENTITYMAPPING_CHECKER"/>
<column name="entity_name" value="ENTITYMAPPING"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="CREATE_USERAGREEMENT_CHECKER"/>
<column name="entity_name" value="USERAGREEMENT"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="UPDATE_BATCH_BUSINESS_STEP_CHECKER"/>
<column name="entity_name" value="BATCH_BUSINESS_STEP"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Granted to whichever roles already hold the corresponding base permission - the same
"grant to roles that already hold the base permission" convention used in
0583_add_loan_originator_checker_permissions.xml. -->
<changeSet id="0586_grant_system_module_checker_permissions" author="cbs-anshul">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, checker.id
FROM m_role_permission rp
JOIN m_permission base ON base.id = rp.permission_id
JOIN m_permission checker ON checker.code = base.code || '_CHECKER'
WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING',
'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -105,14 +105,28 @@ public interface DepositAccountWritePlatformService {
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
/**
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the
* generic `payment` command (the same one the account's own Account Actions menu uses), so every
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit}
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
* Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto
* that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has
* passed; a no-op, returning zero, if still within it. Paid to the linked account because it was
* earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it
* renews from its real maturity amount regardless of which closure type follows. Called as a
* pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same
* one the account's own Account Actions menu uses), so every closure type is available from the
* Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns
* {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
*/
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
/**
* Read-only preview of what renewing this matured FD right now would do -- shown in the FD
* Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns
* isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace,
* currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo,
* linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and
* projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually
* renews).
*/
Map<String, Object> previewFdRenewal(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
}