Compare commits

...
63 Commits
Author SHA1 Message Date
anshulandClaude Opus 5.5 2569e0f836 feat(investment): interest schedule API for Interest Entry
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Add GET /v1/investment/purchases/{id}/interest-schedule. For a holding it
lists every half-yearly coupon and every 31 Mar / 30 Sep closing provision
from purchase to maturity, worked out on the face value still held:
provision = interest earned since the last coupon (30/360), and the coupon
clears what is already booked (purchased accrued interest or the earlier
provision). Lines up to the as-on date are marked due. Calculation only;
nothing is posted.

The day-count and interest arithmetic move into InvestmentInterestCalculator,
shared with Purchase Entry, and are covered by unit tests using Jitendra
Sir's worked example. Duplicate-code errors on the masters now keep the
original exception as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:41 +05:30
anshulandClaude Opus 5.5 81b5850b62 feat(investment): Bank/Party Master, Script Master and Purchase Entry backend
Treasury Investment module, first slice, following the bank's existing CBS
Investment screens:

- Bank / Party Master (m_investment_party): auto party code PTY-nnn, address,
  bank type, GL A/c, RBI code, exposure limit, LEI, counterparty bank details.
- Script Master (m_investment_script): auto script code by bond type
  (G00001, B00001, ...), SLR type (GOI / STATE_GOVT / TREASURY / NON_SLR),
  GL A/c, ISIN, interest rate, issue / maturity dates, issuer details.
- Purchase Entry (m_investment_purchase): one deal = one holding. FY deal
  number (e.g. 202627000001), auto cert no, settlement date from T+n.
  Units, book value, premium/discount, accrued interest (30/360 up to the
  deal date), settlement amount and YTM are computed by the backend.

APIs: /v1/investment/parties, /v1/investment/scripts, /v1/investment/purchases,
all through the command pipeline. Permissions READ/CREATE/UPDATE/DELETE plus
_CHECKER rows are seeded with maker-checker off and granted to roles that
hold MENU_INVESTMENT. Mapped GLs must be enabled ASSET detail accounts.
No GL entry is posted by a purchase yet.

Migrations: 0593_investment_masters, 0594_investment_purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:35:43 +05:30
niteshandClaude Sonnet 5 88157ee9d6 Revert "Enforce same-user maker-checker restriction"
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Reverts commits 2afd4aaf8 and 11a0d502b (both by anshul, same title).
Restores the CHECKER_SUPER_USER self-approval bypass in CommandSourceService
and the six *EditWritePlatformServiceJpaRepositoryImpl classes, and restores
the original isSameMakerCheckerEnabled()/isCheckerSuperUser()-gated same-user
check in PortfolioCommandSourceWritePlatformServiceImpl. Removes the
0588_persist_manually_enabled_maker_checker_permissions.xml migration added
alongside the restriction.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:29:26 +05:30
keshav d7ff4e4a7b Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-05 12:43:38 +05:30
keshav 7068d65422 feat: teller uniqueness, cashier allocation office check and interest posting report updates 2026-10-05 12:42:44 +05:30
nitesh fd827dec27 fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-04 16:02:25 +05:30
nitesh aa264097e7 fix(savings,accounting): fix premature interest calculation date bounds and load interest payable GL mapping on edit
Details of changes:

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

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

3. Interest Payable GL Load on Edit (AccountingConstants & ProductToGLAccountMappingReadPlatformServiceImpl):
- Added INTEREST_PAYABLE(17) enum constant to CashAccountsForSavings in AccountingConstants.
- Mapped CashAccountsForSavings.INTEREST_PAYABLE to SavingProductAccountingDataParams.INTEREST_PAYABLE in ProductToGLAccountMappingReadPlatformServiceImpl.
- Fixes the bug where Liability -> Interest Payable Account GL was not loading in the product edit form (e.g., /products/fixed-deposit-products/:id/edit) even after being saved.
2026-10-04 11:54:28 +05:30
nitesh 16c67f6701 feat: Fixed Deposit - premature close logic, deposit account closure type, transaction validation, domain service and read service updates 2026-10-04 09:12:17 +05:30
nitesh e846892e9f Merge branch 'feature/bugfix' into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Brings in the backend counterpart of "Enforce same-user maker-checker
restriction".

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:00:59 +05:30
niteshandClaude Sonnet 5 9436eab129 feat(security): seed a fixed default platform API key via migration instead of a random one at runtime
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Replaces the previous DefaultApiClientBootstrapper (ApplicationRunner
minting a random key on first startup) with a plain Liquibase seed on the
tenant-store database: one fixed, well-known api_key_hash row, inserted
once via migration 0012, same on every deployment and every tenant --
exactly like this fork already seeds default codes/permissions, rather
than generating a secret at runtime.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 15:36:18 +05:30
anshul e02ab51ee1 Merge branch 'dev-latest' into feature/bugfix
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
2026-09-29 10:21:21 +05:30
nitesh 3a37a5f90f Merge branch 'feature/bugfix' into dev-latest
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:22:31 +05:30
niteshandClaude Sonnet 5 32411c924b fix(accounting): stop the Journal Entry view showing a colliding current account's number for a savings GL entry
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
entity_type_enum=2 (PortfolioProductType.SAVING) is written for both
savings and current-account journal entries -- current products
deliberately share SAVING's GL type space -- so it can't distinguish
them, and m_savings_account/m_current_account each have their own id
sequence, meaning an entity_id can be a real row in both. The account-
number column's COALESCE(ca.account_no, sa.account_no) always preferred
the current account's number whenever both matched, so a savings
deposit's own journal entry displayed an unrelated current account's
number instead of the account it actually posted against.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:38 +05:30
keshav fde2699957 feat: sponsor bank DD payable GL, AML stale review release, and dividend updates
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 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 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 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract 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
2026-09-21 10:23:52 +05:30
niteshandClaude Opus 5 dd4bb4a1c0 feat(shares, cashier): enforce Share Collection entry type on listing, single receipt fetch, product summary
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 10 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 Messaging Smoke Tests / Smoke Test with Kafka (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
Only vouchers tagged with the "Share Collection" entry type reach a share
account. The adoption path already checked this, but the listing did not, so an
already-adopted "General" voucher kept reappearing at Apply Collection and Apply
Additional Shares time. SHARE_COLLECTION_TAGGED_ONLY now filters the listing
query itself, against both the cashier-deposit and clerk-disbursement request
tables.

/unattributed takes an optional clientId and answers with both lists in one
response -- that member's own unapplied receipts under "own", every other
available receipt under "rows", with the member's own excluded from the latter.
The two endpoints read the same table under the same filters, so asking twice
only ever fetched the same rows twice, and keeping the query in two places is
what let the entry-type filter land on one and miss the other. Both now share
unappliedFor().

The entry fee is never posted a second time. A receipt already sitting in an
INCOME GL was recognised as income by the cashier's own entry when it was taken,
so applying the collection now skips both legs for it and counts it only towards
verifying the fee was collected.

Adds GET /sharereports/product-summary: authorised, issued, available and unpaid
shares per product, with the distinctive range and account count.

Adds a series-overlap pre-check to sponsor DD inventory registration, which
previously surfaced as a bare duplicate-key error.

Adds migration 0568 for the columns the clerk share-withdrawal settlement path
reads (settlement_mode, tok_no, cash_settlement_txn_id, cashier_id, disbursed_by,
disbursed_at, disbursement_denomination_data), each changeset guarded so it is a
no-op where the column already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:58:40 +05:30
niteshandClaude Sonnet 5 6046134b51 fix(shares): drop stale client-mismatch check, fix stale client_id on settle
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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
- Remove the client-mismatch validation in the Additional Shares tranche
  submit path (ShareAccountDataSerializer) for parity with the same decision
  already made on the first-application Apply Collection flow -- a voucher a
  counter was shown and allowed to pick should not then be rejected for the
  same reason at the very last step
- Rewrite m_share_advance_collection.client_id to the settling account's own
  client at the moment a voucher is actually applied (both
  ShareAdvanceCollectionApiResource.apply() and
  AdditionalShareTrancheFundingService.settle()) -- a voucher can now be found
  and applied from any member's screen, so whoever last searched it is not
  necessarily who its money became share capital for, and the stale value was
  misnaming the receipt in its own detail popup after settlement

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:56:49 +05:30
nitesh e4fad8b648 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-18 13:11:10 +05:30
niteshandClaude Sonnet 5 e206536a6b feat(shares): reports suite, GL transaction fixes, strict entry-type enforcement
- Add ShareReportsApiResource (final balance, account statement, issued/status/
  nominal/share-wise/guarantor/member-interest reports, enquiry, payment
  certificate, share detail, dividend statement, folio statement, distinctive
  numbers, area-wise, mailing list) and ShareTransferApiResource, applying the
  paid-up-capital predicate everywhere so an unsettled application never
  counts as issued capital
- Add share transfer migration (0567)
- Enforce the original application's collection before a further tranche can
  be applied (ShareAccountDataSerializer), matching the same gate on screen
- Fix postGlOffsetEntry silently discarding the real GL transaction id on the
  clerk-collection fallback posting path, leaving gl_transaction_id blank and
  the receipt's GL-detail popup empty
- Add gl_code to the by-voucher and adoption responses so the GL Account
  picker on screen follows whichever receipt was found or adopted
- Require an exact "Share Collection" entry-type tag to adopt a voucher --
  "General" and untagged rows no longer qualify

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:02:55 +05:30
niteshandClaude Sonnet 5 bf52c2cda1 feat(shares): year-safe voucher numbers, universal Share Collection visibility, unrestricted apply
Voucher numbers now embed the year (PREFIX/DDMMYYYY/NNNN) so the same day
and month a year apart can never produce the same voucher_no text, which
every lookup/dedup across the cash registers matches on literally.

/shareadvancecollection/unattributed now lists every unapplied Share
Collection receipt across every share account screen, not just ownerless
GL deposits -- filtered to rows whose current Entry Type is still Share
Collection even after adoption, with the linked member named for
transparency. Applying a receipt to a share account no longer blocks on a
client mismatch, per product decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:48:22 +05:30
keshav 934c11bf87 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
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-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (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-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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
2026-09-18 10:29:24 +05:30
keshav c1e6e036d0 feat(aml): enforce review locking and sync STR case conclusions to linked alerts 2026-09-18 10:29:17 +05:30
niteshandClaude Sonnet 5 4c0512f154 feat(shares, cashier): tag GL counter entries by purpose, close DD/Share voucher cross-use
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
A cashier Receipt or clerk Collection into a GL account never named who or what it was for,
so a manager applying a share request could adopt a voucher a cashier had actually collected
for a Demand Draft, and vice versa. Adds a GLEntryType code (General/Share Collection/DD Issue,
extensible via Manage Codes) that the counter sets at save time; Share Advance Collection and
Sponsor DD issuance both check it before adopting a voucher, and also cross-check each other's
own consumption tables directly so the guard holds even for untagged legacy rows.

Also:
- Share-Collection-tagged vouchers auto-surface on Apply Share / Additional Share screens
  instead of requiring a manual GL-account search, but only when a matching client_id already
  exists on the row or the request is scoped to a specific member's screen -- a clientless GL
  receipt is never auto-attributed to whoever's screen happened to be open.
- gl_transaction_id column on m_clerk_disbursement_request captures the real GL posting a
  Collection approval makes, previously generated and discarded.
- New GET .../gl-entries endpoint exposes a receipt's actual Dr/Cr legs for detail popups.
- Additional-share tranches restricted to a single funding voucher for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:39:59 +05:30
niteshandClaude Opus 5 b779d04571 fix(accounting): ledger transaction-id integrity, share GL correctness, and validation fixes
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 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 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (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
Ledger transaction ids:
- New LedgerTransactionId generator (PREFIX-YYMMDD-NNNN, e.g. DIVRND-260916-0001) backed by a
  dedicated m_ledger_txn_sequence counter (migration 0563) that belongs to no business table, so
  no table's sequence reset can make an id repeat. Migrated 18 hand-rolled id sites (dividend,
  branch day, closure, year-end, share collection/advance) off ad hoc nanoTime()/PK-concatenation
  strings.
- ShareAccount.getShareAccountTransaction() matched a just-saved transaction by date+shares+price+
  type and took the first hit -- two identical transactions on the same day resolved to the wrong
  one and posted under the older transaction's id. Now matches by id where one exists.

Share accounting -- Cash at Vault fully retired from the share account/product lifecycle:
- New ShareAccountingSuppression lets a register that posts its own accounting tell the platform
  not to post its native version (mirrors VoucherService.withOwnVoucher). Wired into share
  withdrawal, additional-shares apply/approve/reject and account close, so every governed flow now
  posts one balanced entry against the real counterparty instead of the platform's fixed Shares
  Reference placeholder, reversed and reposted.
- Modify Application and Reject (the account's first application, pre-approval) no longer post via
  the native bridge at all -- no money has moved at that stage, so there was nothing to post.
- The native "close" command no longer force-redeems a share account's remaining holding in one
  unguarded step; closing with a balance outstanding is now refused, so redemption can only happen
  through the governed Share Withdrawal flow the frontend already restricts it to.
- "Share Reference (Cash Vault or Suspense)" is optional on the product, not mandatory: validator,
  GL mapping write/merge (including removing a stale mapping when the field is cleared on edit),
  and AccountingProcessorHelper's resolver (now throws a named error instead of NPEing when a
  product carries no mapping) all updated together. Removed from the product UI entirely (create,
  edit, preview, view).
- AdditionalShareTrancheFundingService: submit posts nothing; settle() posts the whole entry itself,
  debiting whichever GL a voucher receipt actually landed in (read back from its own posting) or the
  member's savings account, crediting Share Capital. A voucher-funded tranche whose receipts total
  more than it costs now credits the difference to the member's linked savings account -- every
  named receipt is visited, not just enough to cover the tranche, since one left untouched here was
  still marked APPLIED and its money left untraceable.
- ShareAccountDataSerializer: redeeming every share down to zero is exempt from the product's
  minimum-shares check (the check protects an account staying open with a reduced holding, not one
  being closed via a full exit); an over-payment on a voucher-funded tranche is refused at submit
  when no savings account is linked to receive it, rather than accepted and left unable to settle;
  six failWithCode(code, message) calls in the tranche validator were silently discarding their own
  message for the generic fallback text (failWithCode's second argument is a template arg, not a
  displayed string) -- switched to failWithCodeAndMessage.

Removed: ShareCollectionApiResource, a direct-cash counter-collection endpoint that read
m_share_account.cashier_collected, a column that has never existed in this schema -- no call site
in this codebase's frontend reached it, and no entry it could have produced ever has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:54:04 +05:30
niteshandClaude Opus 5 184d1b8cf8 feat(accounting): GL-funded DD issuance, share-withdrawal GL settlement, and ledger txn-id hardening
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-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
Share withdrawal (clerk):
- Add GL and NONE settlement modes alongside the existing SAVINGS mode. A clerk
  can now credit any GL account on the CR row of a Fund Transfer instead of the
  member's linked savings account; picking the product's own Sundry account
  self-cancels, so it downgrades to NONE and the request stands on leg 1 alone.
- Post Dr Sundry / Cr chosen GL for GL mode; raise a standalone Dr Share /
  Cr Sundry voucher for NONE mode, read back from the ledger.

Sponsor DD:
- Let a clerk fund a Demand Draft by debiting a GL account directly
  (payment_method = GL) under the existing maker-checker flow, reusing the
  shared postGlOffsetEntry() so only the debit-side GL resolution differs.

Cash reports and vouchers:
- Fix the Closing Cash Report total row and opening-balance carry-forward.
- Read the real till GL back from the underlying transaction when raising
  withdrawal, closure and loan vouchers, instead of labelling every leg
  "Cash in Hand".

Ledger transaction-id hardening:
A derived transaction_id text (e.g. "SH" + share transaction id) is not
guaranteed unique if the underlying numeric id is ever reused, which a manual
sequence reset can cause. Queries that read those rows back now disambiguate,
and flag flips can no longer touch an unrelated transaction:
- Read-back sites pick the most recently posted matching rows (ORDER BY id DESC
  + LIMIT) or, where the read happens long after posting, the candidate group
  whose created_date sits closest to the owning request's actioned_at.
- Every UPDATE ... SET reversed = true/false WHERE transaction_id = ? is now
  guarded by the opposite reversed state, so a collision can only flip rows
  that are currently live.
- Covers clerk disbursement, sponsor DD, cheque book, clearing, share tranche
  funding and share withdrawal.

Migrations 0551-0554: settlement GL account and voucher_no columns for the
clerk share withdrawal, dividend claim and sponsor DD issue request tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:16:17 +05:30
keshav 521d61d6b7 feat: AML alert rule disposition, Client Behaviour Window evaluation, Risk Policy parameters & Board Resolution register
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 Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract 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 - 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 - 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 - 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-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-16 12:50:43 +05:30
333 changed files with 34242 additions and 4978 deletions
@@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
// Optional, not mandatory like the three below: it names an account for cash physically
// received at the counter for a share purchase, which every register in this bank now
// books against the account the money actually reached instead. Left absent, a product
// simply carries no such mapping, and any code path that still needs one (a genuine
// over-the-counter collection) fails with a named "GL not mapped" error rather than
// silently assuming the till.
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
element);
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
.integerGreaterThanZero();
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
return accountMappingDetails;
}
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
@Override
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
PortfolioProductType.CURRENT.getValue());
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
return setCashSavingsProductToGLAccountMaps(mappings);
}
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
}
return null;
}
@Override
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
@@ -463,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
// Income
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
// Interest Payable is editable on cash too, and optional. Interest posting routes
// through it on either basis, so a product can name one to carry interest declared
// but not yet credited; a product that names none still posts straight to Savings
// Control. Created-or-merged because a product configured before this row existed
// has no mapping to merge into.
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
break;
case ACCRUAL_PERIODIC:
// asset
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
case NONE:
break;
case CASH_BASED:
// asset
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
// asset -- the one optional mapping here, so unlike the three below it needs its own
// handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
// income
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
}
/**
* Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
* the accounting rule.
*
* <p>
* The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
* {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
* changed" for them -- a null value there cannot happen without failing validation first. Shares
* Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
* share products alike) only handles "present": it silently leaves an existing mapping row alone
* when the incoming value is null, rather than clearing it. Editing this one field out of an
* existing product would otherwise leave the product still carrying its old GL underneath, invisible
* on the edit form but still resolvable by any code path that looks it up directly.
*/
private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
final Map<String, Object> changes) {
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
element);
if (shareReferenceId != null) {
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
return;
}
final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
if (existing != null) {
this.accountMappingRepository.delete(existing);
changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
}
}
}
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
Collection<Teller> findTellerByOfficeId(Long officeId);
long countByDeletedFalse();
long countByOfficeIdAndDeletedFalse(Long officeId);
}
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
public boolean existsAny() {
return this.repository.countByDeletedFalse() > 0;
}
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
public boolean existsInOffice(final Long officeId) {
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
}
}
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
public TellerAlreadyExistsException() {
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
}
public TellerAlreadyExistsException(final String officeName) {
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
officeName);
}
}
@@ -270,6 +270,7 @@ public final class AccountingConstants {
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14), //
INTEREST_PAYABLE(17), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
@@ -95,6 +95,62 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentParty() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = null;
this.href = "/investment/parties";
return this;
}
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder createInvestmentScript() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = null;
this.href = "/investment/scripts";
return this;
}
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = scriptId;
this.href = "/investment/scripts/" + scriptId;
return this;
}
public CommandWrapperBuilder createInvestmentPurchase() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = null;
this.href = "/investment/purchases";
return this;
}
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId;
return this;
}
public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT";
@@ -756,6 +812,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteFund(final Long fundId) {
this.actionName = "DELETE";
this.entityName = "FUND";
this.entityId = fundId;
this.href = "/funds/" + fundId;
return this;
}
public CommandWrapperBuilder createReport() {
this.actionName = "CREATE";
this.entityName = "REPORT";
@@ -884,6 +948,70 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createPaymentType() {
this.actionName = "CREATE";
this.entityName = "PAYMENTTYPE";
this.entityId = null;
this.href = "/paymenttypes/template";
return this;
}
public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) {
this.actionName = "UPDATE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) {
this.actionName = "DELETE";
this.entityName = "PAYMENTTYPE";
this.entityId = paymentTypeId;
this.href = "/paymenttypes/" + paymentTypeId;
return this;
}
public CommandWrapperBuilder createSponsorBank() {
this.actionName = "CREATE";
this.entityName = "SPONSORBANK";
this.entityId = null;
this.href = "/sponsorbanks";
return this;
}
public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) {
this.actionName = "UPDATE";
this.entityName = "SPONSORBANK";
this.entityId = sponsorBankId;
this.href = "/sponsorbanks/" + sponsorBankId;
return this;
}
public CommandWrapperBuilder updateSponsorDdGlMapping() {
this.actionName = "UPDATE";
this.entityName = "SPONSORDDGLMAPPING";
this.entityId = null;
this.href = "/sponsordd/gl-mapping";
return this;
}
public CommandWrapperBuilder updateInstitutionProfile() {
this.actionName = "UPDATE";
this.entityName = "INSTITUTIONPROFILE";
this.entityId = null;
this.href = "/institutionprofile";
return this;
}
public CommandWrapperBuilder updateBranchProfile(final Long officeId) {
this.actionName = "UPDATE";
this.entityName = "BRANCHPROFILE";
this.entityId = officeId;
this.href = "/branchprofile/" + officeId;
return this;
}
public CommandWrapperBuilder createLoanProduct() {
this.actionName = "CREATE";
this.entityName = "LOANPRODUCT";
@@ -3390,6 +3518,13 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateCurrency() {
this.actionName = "UPDATE";
this.entityName = "CURRENCY";
this.href = "/currencies";
return this;
}
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
this.actionName = "UPDATE";
this.entityName = "EXTERNALSERVICES";
@@ -4730,4 +4865,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;
}
}
@@ -43,6 +43,7 @@ public enum JobName {
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -72,6 +73,8 @@ public enum JobName {
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
; //
private final String name;
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
import org.springframework.stereotype.Component;
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
public class CurrenciesApiResource {
private final OrganisationCurrencyReadPlatformService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
final var command = new CurrencyUpdateCommand();
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency()
.withJson(toApiJsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<CurrencyUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.monetary.handler;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CURRENCY", action = "UPDATE")
public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler {
private final CurrencyWritePlatformService writePlatformService;
@Autowired
public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies");
final List<String> currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes);
final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build();
final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(Map.of("currencies", response.getCurrencies())) //
.build();
}
}
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
SAVING(2, "productType.saving"), //
CLIENT(5, "productType.client"), //
PROVISIONING(3, "productType.provisioning"), //
SHARES(4, "productType.shares"); //
SHARES(4, "productType.shares"), //
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
// product and a current product with the same id read each other's GL rows.
CURRENT(6, "productType.current"); //
private final Integer value;
private final String code;
@@ -52,17 +55,25 @@ public enum PortfolioProductType {
return null;
}
// Matched against each constant's own declared value above, not its declaration position --
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
// (entity_type_enum=6) reached this on the Journal Entries screen.
switch (v) {
case 1:
return LOAN;
case 2:
return SAVING;
case 3:
return CLIENT;
case 4:
return PROVISIONING;
case 5:
case 4:
return SHARES;
case 5:
return CLIENT;
case 6:
return CURRENT;
default:
return null;
}
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "disposition", length = 20)
private String disposition;
/**
* The other rules this same transaction matched, comma-separated.
*
* <p>
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
* stopping them clearing one while escalating another. The rules that did not win are kept here
* so the case file still shows everything that fired.
*/
@Column(name = "also_matched_rules", length = 500)
private String alsoMatchedRules;
/**
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
*
* <p>
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
*/
@Column(name = "highest_severity")
private Integer highestSeverity;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return alert;
}
public String getAlsoMatchedRules() {
return this.alsoMatchedRules;
}
public Integer getHighestSeverity() {
return this.highestSeverity;
}
public void recordHighestSeverity(final Integer severity) {
this.highestSeverity = severity;
}
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
public void recordAlsoMatchedRules(final String ruleCodes) {
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
}
/**
* Whether this alert is being worked by somebody other than the given officer.
*
* <p>
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
* would carry two reviews, and whichever was written last would be the one on file.
*/
public boolean isHeldByAnother(final AppUser officer) {
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
&& !this.reviewedBy.getId().equals(officer.getId());
}
/**
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
*
* <p>
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
*
* <p>
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
*/
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
this.reviewedBy = principalOfficer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
@@ -44,4 +44,18 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
/**
* A customer's still-open alerts inside the observation window, oldest first.
*
* <p>
* What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
* that a partial failure leaves the longest-standing ones dealt with.
*/
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
+ "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC")
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
}
@@ -0,0 +1,163 @@
/**
* 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One rule a transaction matched, and what the officer decided about that rule.
*
* <p>
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
* observation or sends the other five to an STR they do not belong in.
*
* <p>
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
* afterwards must not rewrite what the officer was looking at when they decided.
*/
@Entity
@Table(name = "m_aml_alert_rule")
public class AmlAlertRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "rule_code", nullable = false, length = 100)
private String ruleCode;
@Column(name = "risk_category")
private Integer riskCategory;
@Column(name = "is_governing", nullable = false)
private boolean governing;
@Column(name = "disposition", length = 20)
private String disposition;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
/**
* Whether the officer named this rule, as against it being closed to keep the case consistent.
*
* <p>
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
* the substance of the finding.
*/
@Column(name = "directly_decided", nullable = false)
private boolean directlyDecided;
protected AmlAlertRule() {}
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
final boolean governing) {
final AmlAlertRule finding = new AmlAlertRule();
finding.alert = alert;
finding.ruleCode = ruleCode;
finding.riskCategory = riskCategory;
finding.governing = governing;
return finding;
}
/**
* @param directlyDecided
* true when the officer named this rule; false when it was closed alongside one they named
*/
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
final boolean directlyDecided) {
this.disposition = disposition.name();
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.directlyDecided = directlyDecided;
}
public boolean isDirectlyDecided() {
return this.directlyDecided;
}
public boolean isDecided() {
return this.disposition != null;
}
public boolean isEscalated() {
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
}
public Long getId() {
return this.id;
}
public AmlAlert getAlert() {
return this.alert;
}
public String getRuleCode() {
return this.ruleCode;
}
public Integer getRiskCategory() {
return this.riskCategory;
}
public boolean isGoverning() {
return this.governing;
}
public String getDisposition() {
return this.disposition;
}
public AppUser getReviewedBy() {
return this.reviewedBy;
}
public LocalDateTime getReviewedOn() {
return this.reviewedOn;
}
public String getReviewNotes() {
return this.reviewNotes;
}
}
@@ -0,0 +1,31 @@
/**
* 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.aml.domain;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
List<AmlAlertRule> findByAlertId(Long alertId);
/** Every finding across a page of alerts, in one read rather than one query per alert. */
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
}
@@ -0,0 +1,106 @@
/**
* 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.aml.domain;
/**
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
*
* <p>
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
* fortieth review cost an officer the same effort as the first and changed as little.
*
* <p>
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
* about should come back sooner, not later.
*/
public enum AmlRecurringEscalationTier {
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
ROUTINE(0, 90),
/**
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
* the reviewer is asked to consider whether the rating still fits.
*/
ELEVATED(7, 30),
/**
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
* to be taken by someone senior.
*/
SENIOR(15, 14),
/**
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
* is a decision that has to be defensible on its own - so the review stands for a week only.
*/
MANDATORY_STR(30, 7);
private final int minimumAlertCount;
private final int reviewValidityDays;
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
this.minimumAlertCount = minimumAlertCount;
this.reviewValidityDays = reviewValidityDays;
}
/** The tier a customer sitting on this many alerts falls into. */
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
AmlRecurringEscalationTier tier = ROUTINE;
for (final AmlRecurringEscalationTier candidate : values()) {
if (alertCount >= candidate.minimumAlertCount) {
tier = candidate;
}
}
return tier;
}
public int getMinimumAlertCount() {
return minimumAlertCount;
}
public int getReviewValidityDays() {
return reviewValidityDays;
}
/**
* How far the alert count may climb before a standing review is set aside and the customer returns early.
*
* <p>
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
*/
public int alertGrowthBeforeEarlyReturn() {
return switch (this) {
case ROUTINE -> 5;
case ELEVATED -> 4;
case SENIOR -> 3;
case MANDATORY_STR -> 2;
};
}
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
public boolean expectsStr() {
return this == MANDATORY_STR;
}
}
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
/**
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
* behaviour of lapsing the moment one more alert lands.
*/
@Column(name = "valid_until")
private LocalDate validUntil;
@Column(name = "escalation_tier", length = 20)
private String escalationTier;
/** How many of the customer's open alerts this review actually disposed of. */
@Column(name = "alerts_actioned")
private Integer alertsActioned;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
// always being cleared.
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
review.escalationTier = tier.name();
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
review.alertsActioned = 0;
return review;
}
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
public void recordAlertsActioned(final int count) {
this.alertsActioned = count;
}
public Long getId() {
return id;
}
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public LocalDate getValidUntil() {
return validUntil;
}
public AmlRecurringEscalationTier getEscalationTier() {
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
}
public Integer getAlertsActioned() {
return alertsActioned;
}
/**
* Whether this review still holds the customer out of the review queue.
*
* <p>
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
* lapsing as soon as one more alert arrives.
*/
public boolean stillStands(final int currentAlertCount) {
if (validUntil == null) {
return currentAlertCount <= alertCountAtReview;
}
if (LocalDate.now().isAfter(validUntil)) {
return false;
}
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
}
}
@@ -0,0 +1,83 @@
/**
* 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
/**
* One alert covered by an STR case.
*
* <p>
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
* Principal Officer has to be able to read in one place.
*/
@Entity
@Table(name = "m_aml_str_case_alert")
public class AmlStrCaseAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "str_case_id", nullable = false)
private AmlStrCase strCase;
@ManyToOne(optional = false)
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "added_on_utc", nullable = false)
private LocalDateTime addedOn;
protected AmlStrCaseAlert() {}
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
final AmlStrCaseAlert link = new AmlStrCaseAlert();
link.strCase = strCase;
link.alert = alert;
link.addedOn = LocalDateTime.now();
return link;
}
public Long getId() {
return id;
}
public AmlStrCase getStrCase() {
return strCase;
}
public AmlAlert getAlert() {
return alert;
}
public LocalDateTime getAddedOn() {
return addedOn;
}
}
@@ -0,0 +1,40 @@
/**
* 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.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
/** The alerts one case covers, oldest first - the order the pattern happened in. */
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
/**
* The case already covering an alert, if any.
*
* <p>
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
* one, however it comes to be escalated again.
*/
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
long countByStrCase_Id(Long strCaseId);
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
/** The case already open for a transaction, so escalating it again does not open a second one. */
Optional<AmlStrCase> findByAlertId(Long alertId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
/**
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
*/
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
}
@@ -0,0 +1,219 @@
/**
* 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.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* What one customer's money actually did over a period, and what that says about their rating.
*
* <p>
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
* Neither is a pattern.
*
* <p>
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
* the system was looking at rather than a conclusion with no working.
*/
@Entity
@Table(name = "m_client_behaviour_window")
public class ClientBehaviourWindow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "window_from", nullable = false)
private LocalDate windowFrom;
@Column(name = "window_to", nullable = false)
private LocalDate windowTo;
@Column(name = "txn_count")
private Integer txnCount;
@Column(name = "distinct_months")
private Integer distinctMonths;
@Column(name = "credit_total", precision = 19, scale = 6)
private BigDecimal creditTotal;
@Column(name = "debit_total", precision = 19, scale = 6)
private BigDecimal debitTotal;
@Column(name = "largest_txn", precision = 19, scale = 6)
private BigDecimal largestTxn;
@Column(name = "cash_txn_count")
private Integer cashTxnCount;
@Column(name = "cash_total", precision = 19, scale = 6)
private BigDecimal cashTotal;
@Column(name = "large_txn_count")
private Integer largeTxnCount;
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
@Column(name = "income_ratio", precision = 19, scale = 6)
private BigDecimal incomeRatio;
@Column(name = "alert_count")
private Integer alertCount;
@Column(name = "str_case_count")
private Integer strCaseCount;
@Column(name = "risk_category_at_review")
private Integer riskCategoryAtReview;
/** UP, DOWN or HOLD. */
@Column(name = "suggested_direction", length = 10)
private String suggestedDirection;
@Column(name = "suggested_category")
private Integer suggestedCategory;
@Column(name = "suggestion_reason", length = 500)
private String suggestionReason;
@Column(name = "computed_on")
private LocalDateTime computedOn;
protected ClientBehaviourWindow() {}
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
final ClientBehaviourWindow window = new ClientBehaviourWindow();
window.clientId = clientId;
window.windowFrom = windowFrom;
window.windowTo = windowTo;
window.computedOn = LocalDateTime.now();
return window;
}
public void recordDistinctMonths(final Integer months) {
this.distinctMonths = months;
}
public Integer getDistinctMonths() {
return this.distinctMonths;
}
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
this.txnCount = txnCount;
this.creditTotal = creditTotal;
this.debitTotal = debitTotal;
this.largestTxn = largestTxn;
this.cashTxnCount = cashTxnCount;
this.cashTotal = cashTotal;
this.largeTxnCount = largeTxnCount;
}
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
this.alertCount = alertCount;
this.strCaseCount = strCaseCount;
this.riskCategoryAtReview = riskCategoryAtReview;
}
/**
* Turnover measured against what the customer said to expect.
*
* <p>
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
* unknown as a benign zero would clear every customer the bank never asked.
*/
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
this.declaredAnnualIncome = income;
this.declaredAnnualTurnover = turnover;
final BigDecimal basis = income != null && income.signum() > 0 ? income
: (turnover != null && turnover.signum() > 0 ? turnover : null);
this.incomeRatio = basis == null || periodTurnover == null ? null
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
}
public void recordSuggestion(final String direction, final Integer category, final String reason) {
this.suggestedDirection = direction;
this.suggestedCategory = category;
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
}
public Long getClientId() {
return this.clientId;
}
public LocalDate getWindowFrom() {
return this.windowFrom;
}
public LocalDate getWindowTo() {
return this.windowTo;
}
public Integer getTxnCount() {
return this.txnCount;
}
public BigDecimal getCashTotal() {
return this.cashTotal;
}
public Integer getLargeTxnCount() {
return this.largeTxnCount;
}
public Integer getAlertCount() {
return this.alertCount;
}
public Integer getStrCaseCount() {
return this.strCaseCount;
}
public Integer getRiskCategoryAtReview() {
return this.riskCategoryAtReview;
}
public String getSuggestedDirection() {
return this.suggestedDirection;
}
public Integer getSuggestedCategory() {
return this.suggestedCategory;
}
}
@@ -0,0 +1,43 @@
/**
* 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.client.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
List<ClientBehaviourWindow> findByClientId(Long clientId);
/**
* Clears the windows computed for a given day before the job writes that day's.
*
* <p>
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
* set of windows rather than a customer appearing several times on the review screen with slightly different
* figures.
*/
@Modifying
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
}
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "form15_fy", length = 10)
private String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
/**
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
*/
@Column(name = "is_pep", nullable = false)
private boolean isPep;
@Column(name = "sanctions_match", nullable = false)
private boolean sanctionsMatch;
@Column(name = "adverse_media_match", nullable = false)
private boolean adverseMediaMatch;
/** What the customer told the bank to expect, which their actual turnover is measured against. */
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
// ── CKYCRR ───────────────────────────────────────────────────────────────
/** Central KYC Records Registry number issued by CERSAI */
@Column(name = "ckyc_number", length = 14)
@@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
// Risk profile. These are determinations about the customer rather than about a transaction, and the
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
// period's activity looks.
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
v -> this.sanctionsMatch = v != null ? v : false);
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
v -> this.adverseMediaMatch = v != null ? v : false);
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
v -> this.declaredAnnualIncome = v);
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
v -> this.declaredAnnualTurnover = v);
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
recalculateExpiryForRiskCategory();
}
public boolean isPep() {
return this.isPep;
}
public boolean isSanctionsMatch() {
return this.sanctionsMatch;
}
public boolean isAdverseMediaMatch() {
return this.adverseMediaMatch;
}
public BigDecimal getDeclaredAnnualIncome() {
return this.declaredAnnualIncome;
}
public BigDecimal getDeclaredAnnualTurnover() {
return this.declaredAnnualTurnover;
}
public String getPanNumber() {
return panNumber;
}
@@ -0,0 +1,74 @@
/**
* 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.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
*
* <p>
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
* three times a month needs different figures from one lending to traders.
*/
@Entity
@Table(name = "m_risk_policy_parameter")
public class RiskPolicyParameter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parameter_key", nullable = false, length = 80)
private String parameterKey;
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
private BigDecimal parameterValue;
@Column(name = "description", length = 500)
private String description;
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@Column(name = "last_modified_on")
private LocalDateTime lastModifiedOn;
protected RiskPolicyParameter() {}
public String getParameterKey() {
return this.parameterKey;
}
public BigDecimal getParameterValue() {
return this.parameterValue;
}
public String getDescription() {
return this.description;
}
}
@@ -0,0 +1,23 @@
/**
* 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.client.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand;
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.springframework.stereotype.Component;
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
public class PaymentTypeApiResource {
private final PaymentTypeReadService readPlatformService;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
@GET
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
final var command = new PaymentTypeCreateCommand();
public String createPaymentType(final PaymentTypeCreateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType()
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeCreateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@PUT
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
final PaymentTypeUpdateRequest request) {
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
request.setId(paymentTypeId);
final var command = new PaymentTypeUpdateCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId)
.withJson(jsonSerializer.serialize(request)).build();
command.setPayload(request);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeUpdateResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
@DELETE
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
final var command = new PaymentTypeDeleteCommand();
final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build();
command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build());
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Supplier<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
return response.get();
return jsonSerializer.serialize(result);
}
}
@@ -18,33 +18,50 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
return paymentTypeWriteService.createPaymentType(command.getPayload());
@Autowired
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder()
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,41 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
return paymentTypeWriteService.deletePaymentType(command.getPayload());
@Autowired
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build();
final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -18,33 +18,51 @@
*/
package org.apache.fineract.portfolio.paymenttype.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
@Service
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
@Override
@Transactional
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
return paymentTypeWriteService.updatePaymentType(command.getPayload());
@Autowired
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Transactional
@Override
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
return CommandHandler.super.fallback(command, t);
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder()
.id(command.entityId())
.name(command.stringValueOfParameterNamed("name"))
.description(command.stringValueOfParameterNamed("description"))
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
.position(command.longValueOfParameterNamed("position"))
.codeName(command.stringValueOfParameterNamed("codeName"))
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
.build();
final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(response.getResourceId()) //
.build();
}
}
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
private final Integer value;
private final String code;
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
case 600 -> TRANSFER_TO_GL;
default -> INVALID;
};
}
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
return this.equals(PARTIAL_REINVEST);
}
/**
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
* Only the fixed-deposit premature-closure handler implements it -- see
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
* terminal "pay the balance out in cash" branch.
*/
public boolean isTransferToGl() {
return this.equals(TRANSFER_TO_GL);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount";
@@ -208,7 +212,18 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
// credited straight to this GL instead of a savings account or cash.
public static final String toGlAccountIdParamName = "toGlAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
// per-request flag rather than a stored account field.
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
// penalty and why.
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -237,7 +252,7 @@ public final class DepositsApiConstants {
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
SavingsApiConstants.taxGroupIdParamName));
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
@@ -247,11 +262,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -373,6 +390,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
fixedDepositRequestData.add("feeToIncomeAccountMappings");
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
fixedDepositRequestData.add("otherGlMappingsMap");
return fixedDepositRequestData;
}
@@ -484,6 +503,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
recurringDepositRequestData.add("feeToIncomeAccountMappings");
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
recurringDepositRequestData.add("otherGlMappingsMap");
return recurringDepositRequestData;
}
@@ -511,6 +532,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import lombok.Getter;
/**
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
* server regardless of what a client happened to also send for the plain waive-penalty flag.
*/
@Getter
public enum PrematureClosureReasonType {
INVALID(0, "prematureClosureReasonType.invalid", false), //
NORMAL(100, "prematureClosureReasonType.normal", false), //
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
private final Integer value;
private final String code;
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
* waive-penalty flag also sent -- see the class javadoc. */
private final boolean autoWaived;
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
this.value = value;
this.code = code;
this.autoWaived = autoWaived;
}
public static PrematureClosureReasonType fromInt(final Integer v) {
if (v == null) {
return INVALID;
}
return switch (v) {
case 100 -> NORMAL;
case 200 -> PREMATURE_EXTENSION;
case 300 -> CONVERSION_TO_ANOTHER_TD;
case 400 -> DECEASED_SETTLEMENT;
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
default -> INVALID;
};
}
public boolean isInvalid() {
return this.equals(INVALID);
}
}
@@ -286,6 +286,15 @@ public class SavingsApiConstants {
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
public static final String validForPeriodParamName = "validForPeriod";
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
public static final String odMinLimitParamName = "odMinLimit";
public static final String odSecurityTypeParamName = "odSecurityType";
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
public static final String odExcessAllowedParamName = "odExcessAllowed";
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
public static final String odPenalRateParamName = "odPenalRate";
// Status of Account fields
public static final String accountStatusParamName = "accountStatus";
@@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private String avgBalanceCalcPeriod;
@Getter @Setter private String validForPeriod;
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
@Getter @Setter private BigDecimal odMinLimit;
@Getter @Setter private String odSecurityType;
@Getter @Setter private Boolean odExcessAllowed;
@Getter @Setter private BigDecimal odExcessLimitPercent;
@Getter @Setter private BigDecimal odPenalRate;
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
@Getter @Setter private Map<String, Object> accountFacilityConfig;
@@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable {
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
target.setValidForPeriod(source.getValidForPeriod());
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
target.setOdMinLimit(source.getOdMinLimit());
target.setOdSecurityType(source.getOdSecurityType());
target.setOdExcessAllowed(source.getOdExcessAllowed());
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
target.setOdPenalRate(source.getOdPenalRate());
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
case TRANSFER_TO_GL:
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
break;
}
return optionData;
}
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
@Column(name = "status_enum", nullable = false)
private Integer statusEnum = 100;
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
// 600=BlockDebit
@Column(name = "sub_status_enum", nullable = false)
private Integer subStatusEnum = 0;
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
// active) are the only statuses a second application on the same product should actually collide
// with.
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
}
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "office_id", nullable = false)
private Office office;
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
@Column(name = "transaction_type_enum", nullable = false)
private Integer transactionTypeEnum;
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
return txn;
}
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate) {
CurrentAccountTransaction txn = new CurrentAccountTransaction();
txn.currentAccount = account;
txn.office = office;
txn.transactionTypeEnum = 19;
txn.amount = amount;
txn.transactionDate = transactionDate;
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
txn.submittedOnDate = transactionDate;
return txn;
}
// transactionTypeEnum = 7 (Reversal / Undo)
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
/**
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
*/
boolean escheat(Long accountId);
}
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
SavingsApiConstants.glAccountIdParamName));
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
/** How long an OD sanction on this scheme runs before it has to be renewed. */
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
/** Excess drawing is a small tolerance over the limit, never a second limit. */
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, null);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
}
if (null != isDormancyActive && isDormancyActive) {
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
}
}
validateOverdraftParams(baseDataValidator, element);
validateOverdraftParams(baseDataValidator, element, product);
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
}
}
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
/**
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
* as RBI requires for advances, or at most quarterly.
*
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
* checked against the scheme it will produce; on create it is null.
*/
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
final CurrentProduct product) {
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
}
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
: product != null && product.isAllowOverdraft();
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
product == null ? null : product.getOverdraftLimit());
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
product == null ? null : product.getMinOverdraftForInterestCalculation());
if (!allowOverdraft) {
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
.zeroOrPositiveAmount();
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
return;
}
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
}
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
.zeroOrPositiveAmount();
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
final String securityType = stringOrStored(element, odSecurityTypeParamName,
product == null ? null : product.getOdSecurityType());
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
: product != null && product.isOdExcessAllowed();
if (excessAllowed) {
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
product == null ? null : product.getOdExcessLimitPercent());
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
.notGreaterThanMax(MAX_EXCESS_PERCENT);
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
product == null ? null : product.getOdPenalRate());
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
}
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
: product == null ? null : product.getInterestPostingPeriodType();
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
SavingsPostingInterestPeriodType.QUATERLY.getValue());
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
: product == null ? null : product.getInterestCalculationDaysInYearType();
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
}
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
: stored;
}
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
: stored;
}
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
}
}
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import com.google.gson.JsonArray;
import jakarta.persistence.CascadeType;
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
@Column(name = "od_account_closed_zero_balance_in_day_closing")
private boolean odAccountClosedZeroBalanceInDayClosing = false;
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
@Column(name = "od_min_limit", scale = 6, precision = 19)
private BigDecimal odMinLimit;
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
@Column(name = "od_security_type", length = 20)
private String odSecurityType;
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
@Column(name = "od_excess_allowed")
private boolean odExcessAllowed = false;
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
private BigDecimal odExcessLimitPercent;
@Column(name = "od_penal_rate", scale = 6, precision = 19)
private BigDecimal odPenalRate;
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
private CurrentProductFacilityConfig facilityConfig;
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
this.odAccountClosedZeroBalanceInDayClosing = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
actualChanges.put(odMinLimitParamName, newValue);
this.odMinLimit = newValue;
}
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
actualChanges.put(odSecurityTypeParamName, newValue);
this.odSecurityType = newValue;
}
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
actualChanges.put(odExcessAllowedParamName, newValue);
this.odExcessAllowed = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
actualChanges.put(odExcessLimitPercentParamName, newValue);
this.odExcessLimitPercent = newValue;
}
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
actualChanges.put(odPenalRateParamName, newValue);
this.odPenalRate = newValue;
}
if (!this.odExcessAllowed) {
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
// Clear OD-specific fields when overdraft is disabled
if (!this.allowOverdraft) {
this.avgBalanceCalcPeriod = null;
this.validForPeriod = null;
this.odAccountClosedZeroBalanceInDayClosing = false;
this.odMinLimit = null;
this.odSecurityType = null;
this.odExcessAllowed = false;
this.odExcessLimitPercent = null;
this.odPenalRate = null;
}
return actualChanges;
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
public BigDecimal getOdMinLimit() { return odMinLimit; }
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
public String getOdSecurityType() { return odSecurityType; }
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
public boolean isOdExcessAllowed() { return odExcessAllowed; }
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
public BigDecimal getOdPenalRate() { return odPenalRate; }
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
}
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
}
if (command.parameterExists(odMinLimitParamName)) {
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
}
if (command.parameterExists(odSecurityTypeParamName)) {
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
}
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
product.setOdExcessAllowed(excessAllowed);
if (excessAllowed) {
if (command.parameterExists(odExcessLimitPercentParamName)) {
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
}
if (command.parameterExists(odPenalRateParamName)) {
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
}
}
}
return product;
@@ -0,0 +1,115 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.currentproduct.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
*
* <p>
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
* the two can never meet.
*
* <p>
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
*/
@Component
@RequiredArgsConstructor
public class CurrentProductGlMappingWriter {
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
static {
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
}
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
private final JdbcTemplate jdbcTemplate;
/**
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
*
* @param accountingRule
* the product's accounting rule after this request (1 = NONE)
*/
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (accountingRule == null || accountingRule == 1) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
return changes;
}
final JsonElement parsed = command.parsedJson();
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
final String param = entry.getKey();
if (!json.has(param)) {
continue;
}
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
final Long existing = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL",
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
if (java.util.Objects.equals(existing, glAccountId)) {
continue;
}
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
if (glAccountId != null) {
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
}
changes.put(param, glAccountId);
}
return changes;
}
/** Removes every mapping of a deleted product. */
public void deleteAll(final Long productId) {
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
}
}
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
productData.setValidForPeriod(rs.getString("validForPeriod"));
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
productData.setOdSecurityType(rs.getString("odSecurityType"));
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
return productData;
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final CurrentProductGlMappingWriter glMappingWriter;
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
}
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
DepositAccountType.SAVINGS_DEPOSIT);
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
}
// accounting related changes
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
DepositAccountType.SAVINGS_DEPOSIT);
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
product.getAccountingType());
changes.putAll(accountingMappingChanges);
if (!changes.isEmpty()) {
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
this.currentProductRepository.delete(product);
this.glMappingWriter.deleteAll(productId);
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
}
}
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
"glAccountId", glAccountId);
}
}
/**
* Refuses to draw more out of a holding account than it holds.
*
* <p>
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
* liability with a negative balance is not a small number - it says the bank owes less than
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
* and the figure is small enough to sit unnoticed inside a head that totals positive.
*
* <p>
* Checked here rather than at each call site because the accounts this protects are reached by
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
* the next one added will not have.
*
* @param glAccountId the account being drawn on.
* @param amount how much the caller wants to take out.
* @param what the account's role in the caller's own words, for the message a clerk reads.
*/
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
final java.math.BigDecimal amount, final String what) {
if (glAccountId == null || amount == null || amount.signum() <= 0) {
return;
}
// Credits less debits: these are liability-natured holding accounts, so the balance is what
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
// reversal pair nets to nothing, and counting either side alone moves the balance by the
// whole amount.
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
FROM acc_gl_journal_entry
WHERE account_id = ?
AND (reversed IS NULL OR reversed = false)
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
""", java.math.BigDecimal.class, glAccountId);
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
if (balance.compareTo(amount) < 0) {
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
+ "difference would leave the account with a negative balance, which a "
+ "holding account cannot have.",
"glAccountId", glAccountId, amount);
}
}
}
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
// cash-based path.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the same helper the cash processor uses, so a posting states
// the whole chain the product is configured for -- out of the payable, through
// the interest-paid account, into the member's balance -- rather than the single
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
}
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
// A premature FD closure that reprices interest downward (see
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
// interest-posting transaction -- a clawback of interest already recognised in earlier
// periods, not "zero interest, nothing to post". Skipping it here (the original
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
// in sync instead.
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
if (amountSign != 0) {
final boolean isClawback = amountSign < 0;
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
final boolean effectiveIsReversal = isReversal ^ isClawback;
// Routed through the payable and paid accounts the product names, so the society
// can read interest declared but not yet credited as its own balance. Collapses
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, amount, isReversal);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ")
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
// m_current_account -- newer write paths tag current-account entries with it
// instead of reusing SAVING's type, specifically so this case needs no guessing.
//
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
// and current-account entries by older write paths not yet updated to the above --
// current products used to deliberately share SAVING's GL type space (see
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
// m_current_account each have their own id sequence, so an entity_id can be a real
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
// account's number even for an entry that is genuinely a savings one.
// savings_transaction_id is the one unambiguous signal available for those older
// entries -- only a real savings transaction sets it -- so it decides first; only
// when it's absent does the current-account guess apply.
.append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" ELSE NULL ")
.append(" END as savingsAccountNo ");
@@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -631,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
if (cashBasedAccountingEnabled) {
// A register that knows the transaction's real counterparty posts the entry itself, in one
// balanced pair against that account, and says so by wrapping the command in
// ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second
// entry for the same money, booked against the product's fixed Shares Reference account --
// which is what that register is avoiding in the first place.
if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) {
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
accrualBasedAccountingEnabled);
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
@@ -0,0 +1,157 @@
/**
* 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.accounting.journalentry.service;
import java.sql.Date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry.
*
* <p>
* Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two
* shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key
* ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing,
* so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to
* share one transaction id and render as a single doubled voucher. Others concatenated
* {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen
* ({@code "CASHMIG-1-20260913-923935527932300"}).
*
* <p>
* This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}:
*
* <pre>
* SHWGL-260916-0001 the first share-withdrawal GL settlement posted on 16 Sep 2026
* DIVRND-260916-0007 the seventh dividend rounding posted that day
* </pre>
*
* The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each
* day. That makes a transaction id self-describing: a reader can tell what it was and when it happened
* without looking anything up.
*
* <p>
* Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a
* counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a
* single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two
* users posting at the same instant are serialised by the database rather than handed the same number - the
* same mechanism {@code m_voucher_sequence} already uses for voucher numbers.
*
* <p>
* The counter alone would still repeat if that table were ever truncated or rewound by hand, which is
* precisely the accident being defended against, so the minted id is also checked against the ledger before
* being handed back and the counter is advanced past anything already in use. That makes a duplicate
* impossible by construction rather than by convention: the id is only returned once no row holds it.
*
* <p>
* Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again
* (every call site mints one, posts with it, and never derives it a second time), so old and new shapes
* coexist without anything needing to read both.
*
* <p>
* Postings made by Fineract's own command path are not covered here and keep their native
* {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and
* several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most
* recently posted rows rather than assuming the text is unique.
*/
public final class LedgerTransactionId {
/** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */
private static final int MAX_PROBES = 50;
private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd");
private LedgerTransactionId() {}
/**
* Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}.
*
* @param prefix
* short uppercase code naming the event, from {@link LedgerTxnPrefix}
*/
public static String next(final JdbcTemplate jdbcTemplate, final String prefix) {
// Tenant timezone, not the server's - the day the counter resets on has to be the branch's
// business day, otherwise a late-evening posting lands in the wrong series.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String datePart = today.format(YYMMDD);
for (int probe = 0; probe < MAX_PROBES; probe++) {
final int serial = nextSerial(jdbcTemplate, prefix, today);
final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial);
if (!isTaken(jdbcTemplate, candidate)) {
return candidate;
}
// Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks
// it forward until it clears whatever is already posted, which self-heals the counter.
}
throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES
+ " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today.");
}
/**
* Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}.
*
* <p>
* Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that
* stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that
* started them, so the id has to come out the same on the second pass - a counter would hand the
* remainder a different id and split one posting across two transactions. This builds the id from
* what identifies the run instead: the same prefix, date and discriminator always produce the same
* string, and no counter is consumed.
*
* <p>
* Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the
* product being posted, say), which is what makes the id reproducible in the first place. Use
* {@link #next} for everything else - a posting that happens once wants a counter, not a formula.
* The {@code P} marks the tail as a discriminator so it is not misread as a serial.
*
* @param date
* the date the posting belongs to, which is not always today - interest can be posted
* for an earlier day
* @param discriminator
* what makes this run distinct from another on the same date, e.g. the product id
*/
public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) {
return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator);
}
/**
* Reserves the next counter value for one (prefix, day) series.
*
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and
* increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed.
*/
private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) {
return jdbcTemplate.queryForObject("""
INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number)
VALUES (?,?,1)
ON CONFLICT (prefix, series_date)
DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1
RETURNING last_number
""", Integer.class, prefix, Date.valueOf(day));
}
private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) {
final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)",
Boolean.class, transactionId);
return Boolean.TRUE.equals(taken);
}
}
@@ -0,0 +1,87 @@
/**
* 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.accounting.journalentry.service;
/**
* The event codes that open a transaction id minted by {@link LedgerTransactionId}.
*
* <p>
* They live together in one place so a reader can decode any transaction id on a screen from a single list,
* and so no two features can drift into naming the same event differently - which had already happened:
* dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from
* another, for the same event.
*
* <p>
* Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of
* the audit trail once it has been posted, so treat these as append-only: add a new one for a new event
* rather than renaming an existing one, which would leave already-posted entries speaking a language nothing
* else uses any more.
*/
public final class LedgerTxnPrefix {
private LedgerTxnPrefix() {}
// A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the
// native redeem command already minted ("SH" + share transaction id), because its debit leg is
// that command's own and the two have to group as one balanced entry.
// ── Share tranche funding ─────────────────────────────────────────────────
/** Additional share tranche: reversal of the stock reference posting. */
public static final String SHARE_TRANCHE_REFERENCE = "SHTREF";
/** Additional share tranche funded from the member's savings account. */
public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV";
// ── Share collection and advances ─────────────────────────────────────────
/** Share application money collected. */
public static final String SHARE_COLLECTION = "SHCOL";
/** Share advance collected against a future allotment. */
public static final String SHARE_ADVANCE = "SHADV";
/** Share advance applied to an allotment. */
public static final String SHARE_ADVANCE_APPLIED = "SHAPP";
// ── Dividend ──────────────────────────────────────────────────────────────
/** Dividend claim paid out to a member. */
public static final String DIVIDEND_CLAIM = "DIVCLM";
/** Dividend declaration posted. */
public static final String DIVIDEND_DECLARED = "DIVDCL";
/** Dividend declaration approved. */
public static final String DIVIDEND_APPROVED = "DIVAPR";
/** Dividend payout closed off. */
public static final String DIVIDEND_CLOSED = "DIVCLS";
/** Rounding difference on a dividend payout. */
public static final String DIVIDEND_ROUNDING = "DIVRND";
/** Unclaimed dividend forfeited. */
public static final String DIVIDEND_FORFEIT = "DIVFRF";
// ── Branch day and cash ───────────────────────────────────────────────────
/** Opening cash migrated into the branch day register. */
public static final String CASH_MIGRATION = "CSHMIG";
/** Rounding difference on a clerk's day closure. */
public static final String CLOSURE_ROUNDING = "CLSRND";
// ── Year end ──────────────────────────────────────────────────────────────
/** Year-end transfer of income into the profit account. */
public static final String YEAR_END_PROFIT = "YEPFT";
/** Year-end closure of an income or expense account. */
public static final String YEAR_END_CLOSE = "YECLS";
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
}
@@ -0,0 +1,83 @@
/**
* 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.accounting.journalentry.service;
import java.util.function.Supplier;
/**
* Lets a register that posts a share transaction's accounting itself tell the platform not to post its
* own.
*
* <h3>Why this exists</h3>
*
* <p>
* Fineract books every share transaction against a fixed "Shares Reference" account taken from the
* product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money
* actually did. That account is a placeholder for a counterparty the platform does not know, and in a
* real core banking ledger there is no such thing — a redemption paid in cash credits the cash
* account, and one settled by transfer credits the account it was transferred to. Naming a single
* asset account for both leaves every redemption sitting against a GL the money never touched, which
* then has to be corrected by hand.
*
* <p>
* The governed clerk registers already know the real counterparty, because the clerk keyed it: the
* linked savings account, or the GL picked on the transfer's credit row. They post the redemption
* themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform
* post its version first and then reverse the half that was wrong, which left a dead Cr Shares
* Reference row behind on every redemption and made the ledger read as though cash had moved.
*
* <h3>Why a flag and not the product's accounting rule</h3>
*
* <p>
* Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE})
* would silence the platform everywhere, including the share account screen's own Redeem action, which
* no register covers — those redemptions would post nothing at all and go unnoticed, because that
* branch fails silently by design. Scoping the suppression to the one call that is about to post its
* own entry leaves every other path exactly as it is.
*
* <p>
* A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing
* it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to
* hang the flag on, and nesting must not switch the suppression off early for an outer caller.
*/
public final class ShareAccountingSuppression {
private static final ThreadLocal<Boolean> CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE);
private ShareAccountingSuppression() {}
/**
* Runs {@code command} with the platform's own share journal entries suppressed, for a caller that
* posts the transaction's accounting itself.
*/
public static <T> T withOwnShareEntries(final Supplier<T> command) {
final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get();
CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE);
try {
return command.get();
} finally {
CALLER_POSTS_SHARE_ENTRIES.set(previous);
}
}
/** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */
public static boolean shouldPostShareEntries() {
return !CALLER_POSTS_SHARE_ENTRIES.get();
}
}
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
break;
case CASH_BASED:
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
// Interest Payable is saved on cash too, and optionally: interest posting routes
// through it on either basis (see AccountingProcessorHelper
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
// yet credited to the member reads as the bank's own liability rather than appearing
// only at the moment it is paid. Mapped only when the screen supplies it - a product
// naming none still posts straight to Savings Control, as it always did.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
element)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
}
break;
case ACCRUAL_PERIODIC:
@@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -117,7 +119,7 @@ public class IncomeProfitTransferService {
final String currencyCode = resolveCurrency(incomeGlId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YEPROFIT-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT);
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
@@ -210,7 +212,7 @@ public class IncomeProfitTransferService {
final String currencyCode = resolveCurrency(glId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE);
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
@@ -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();
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). Same
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -222,7 +224,10 @@ public class SecurityConfig {
// currency
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies"))
// the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) -
// this matcher previously targeted POST, which meant PUT fell through to the generic
// fullyAuthenticated() catch-all with no permission check at all.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
// notes: read
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
@@ -355,11 +360,18 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
// payment type
// NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a
// second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and
// no PUT/DELETE matcher existed at all, so those requests fell through to the generic
// fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher
// per method, each requiring its own correct permission code.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
// mix: taxonomy
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
@@ -390,12 +402,41 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
// working days
// NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between
// WORKING and DAYS), which did not match the actual seeded permission codes
// (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS")
// sponsor banks
// NOTE: no matcher previously existed at all for this path - POST/PUT both fell
// through to the generic fullyAuthenticated() catch-all below with no permission
// check whatsoever.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK")
// sponsor DD GST/GL settings
// NOTE: no matcher previously existed at all for this path either. gl-mapping is a
// genuine upsert (one call both creates and updates the single settings row), so it is
// covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING")
// bank profile (institution profile)
// NOTE: no matcher previously existed at all for this path - the PUT relied solely on
// the in-resource validateHasPermissionTo() check, which this change replaces with the
// command-source pipeline. Added here for the same defense-in-depth already applied to
// Currency/Payment Type/Working Days.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE")
// branch profile
// NOTE: no matcher previously existed at all for this path either, same rationale as
// institution profile above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
@Override
public Collection<TellerData> getTellers(Long officeId) {
return retrieveAllTellers(false);
if (officeId == null) {
return retrieveAllTellers(false);
}
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
}
@Override
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
try {
this.context.authenticatedUser();
if (this.tellerRepositoryWrapper.existsAny()) {
throw new TellerAlreadyExistsException();
}
final Long officeId = command.longValueOfParameterNamed("officeId");
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
// final Office parent =
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
// its own cash, so a second branch needs a vault of its own.
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
throw new TellerAlreadyExistsException(tellerOffice.getName());
}
final Teller teller = Teller.fromJson(tellerOffice, command);
// pre save to generate id for use in office hierarchy
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
import org.springframework.stereotype.Component;
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) {
public String update(@Valid final WorkingDaysUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays()
.withJson(toApiJsonSerializer.serialize(request)).build();
final var command = new WorkingDaysUpdateCommand();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
command.setCommandId(System.currentTimeMillis());
command.setCreatedAt(Instant.now());
command.setPayload(request);
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
@GET
@@ -18,38 +18,46 @@
*/
package org.apache.fineract.organisation.workingdays.handler;
import java.util.Collections;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
@Service
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
@Autowired
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
}
@Transactional
@Override
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
WorkingDaysUpdateRequest request = command.getPayload();
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
if (changes == null) {
changes = Collections.emptyMap();
}
public CommandProcessingResult processCommand(final JsonCommand command) {
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
.recurrence(command.stringValueOfParameterNamed("recurrence"))
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
.extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments"))
.extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays"))
.build();
final Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(changes) //
.build();
}
}
@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.service;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
/**
* A book debit: money leaves a savings or current account by book entry, not as cash.
*
* <p>
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
* report shows.
*
* <p>
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
* transaction, so a later failure rolls the debit back with everything else.
*/
@Service
@RequiredArgsConstructor
public class AccountBookDebitService {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
/** Debits a savings account; returns the m_savings_account_transaction id. */
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo());
ps.setBigDecimal(7, balance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** Debits a current account; returns the m_current_account_transaction id. */
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId, final String note) {
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setString(5, refNo());
ps.setDate(6, sqlDate);
ps.setBigDecimal(7, balance.subtract(amount));
ps.setString(8, remarks);
ps.setLong(9, userId);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
accountId);
}
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
if (status != 300) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
kind + " account " + accountId + " is not active.", accountId);
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
final BigDecimal available = balance.subtract(onHold);
if (available.compareTo(amount) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
+ ", Requested: " + amount,
accountId);
}
return balance;
}
private static String refNo() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
}
}
@@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
private final KycRestrictionGuard kycRestrictionGuard;
@Transactional
@Override
@@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
toSavingsAccount.productId(), false, toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
@@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
@@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
@@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) {
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
account.getId(), currentTxnId, txnDate, amount);
}
return txn;
}
@@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
*/
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
@@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
* Deposit amount into a current account and save the transaction.
*/
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
final Long counterpartEntityId) {
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount);
@@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
counterpartEntityId);
return txn;
}
@@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
@@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, counterpartEntityId,
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
currentTxnId, txnDate, amount);
} else {
// DR: counterpart (savings/current) GL CR: current account GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
currentTxnId, txnDate, amount);
}
}
@@ -1,140 +1,141 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class AccountConfiguration {
@Bean
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
SqlValidator sqlValidator) {
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
}
@Bean
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
}
@Bean
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
DatabaseSpecificSQLGenerator sqlGenerator) {
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
}
@Bean
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
accountTransferDetailRepository, standingInstructionRepository);
}
}
@@ -61,6 +61,8 @@ public interface ShareAccountApiConstants {
String externalid_paramname = "externalId";
String resolutionnumber_paramname = "resolutionNumber";
/** Date of the board meeting that passed the resolution. */
String resolutiondate_paramname = "resolutionDate";
String currency_paramname = "currencyCode";
@@ -146,7 +148,7 @@ public interface ShareAccountApiConstants {
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
resolutionnumber_paramname, resolutiondate_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.api;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -30,4 +31,13 @@ public class AlertReviewRequest {
private String disposition;
private String notes;
/**
* The rules being decided, when the officer is deciding some of them rather than the whole alert.
*
* <p>
* Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level
* Whitelist and Suspicious buttons send and what this endpoint has always done.
*/
private List<String> ruleCodes;
}
@@ -71,7 +71,13 @@ public class AmlAlertApiResource {
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition());
if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) {
// Deciding named rules within the alert. The alert's own status follows from what is left undecided, so
// it is not set here.
this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST",
request.getNotes());
} else if (escalating) {
this.writePlatformService.escalate(alertId, request.getNotes());
} else {
this.writePlatformService.whitelist(alertId, request.getNotes());
@@ -72,6 +72,17 @@ public class AmlCtrReportApiResource {
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
}
@GET
@Path("pending-lines")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reportable cash transactions not yet in any generated CTR",
description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.")
public String retrievePendingLines() {
this.context.authenticatedUser();
return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines());
}
@POST
@Path("generate")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,172 @@
/**
* 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.aml.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService;
import org.springframework.stereotype.Component;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* Raised from the Risk Review Report, which already works out which customers are rated below what
* their behaviour warrants. The rating moves only when a checker approves, and the change is written
* into the customer's risk history with the reason given.
*/
@Path("/v1/aml/risk-change-requests")
@Component
@Tag(name = "Client Risk Category Change",
description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.")
@RequiredArgsConstructor
public class ClientRiskChangeApiResource {
private final PlatformSecurityContext context;
private final ClientRiskChangeRequestService service;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which
* Fineract's global handler does not map - left uncaught they reach the client as an opaque 500
* with no message. Wrapped so the reviewer is told what actually stopped them.
*/
private <T> T safely(final Supplier<T> action) {
try {
return action.get();
} catch (final IllegalArgumentException | IllegalStateException e) {
throw apiError(400, e.getMessage());
}
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(
Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List risk category change requests",
description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.")
public String retrieveAll(@QueryParam("status") final String status) {
this.context.authenticatedUser();
return this.gson.toJson(this.service.retrieveAll(status));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request a change to a customer's risk category",
description = "Maker step. Nothing moves until a checker approves.")
public CommandProcessingResult request(final RiskChangeRequest request) {
return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason()));
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a risk category change",
description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.")
public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) {
return safely(() -> this.service.approve(requestId));
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.")
public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) {
return safely(() -> this.service.reject(requestId, request.getRejectionReason()));
}
/** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */
public static final class RiskChangeRequest {
private Long clientId;
private Integer requestedCategory;
private String reason;
public Long getClientId() {
return clientId;
}
public void setClientId(Long clientId) {
this.clientId = clientId;
}
public Integer getRequestedCategory() {
return requestedCategory;
}
public void setRequestedCategory(Integer requestedCategory) {
this.requestedCategory = requestedCategory;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
public static final class RiskChangeRejectRequest {
private String rejectionReason;
public String getRejectionReason() {
return rejectionReason;
}
public void setRejectionReason(String rejectionReason) {
this.rejectionReason = rejectionReason;
}
}
}
@@ -30,6 +30,16 @@ import java.time.LocalDateTime;
*/
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory,
/** Other rules the same transaction matched, comma-separated. Null when only one fired. */
String alsoMatchedRules,
/** Worst severity across every rule the transaction matched, not only the governing one. */
Integer highestSeverity,
/**
* Every rule the transaction matched, governing one first, each with its description and severity. The
* comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands
* to show, because a bare rule code does not tell an officer what was detected or how serious it is.
*/
java.util.List<AmlMatchedRuleData> matchedRules)
implements Serializable {
}
@@ -0,0 +1,43 @@
/**
* 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.aml.data;
import java.io.Serializable;
/**
* One rule a transaction matched, as the alert screen needs to show it.
*
* <p>
* An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An
* officer deciding what to do with a transaction has to know what each of those codes means and how serious it is -
* "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision.
*
* @param governing
* whether this is the rule the alert was raised under. One transaction raises one alert, under whichever
* rule most warrants attention, so exactly one entry in the list carries this.
* @param riskCategory
* 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns
* rather than graded suspicions, so they carry no band.
*/
public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing,
/** WHITELIST, ESCALATE, or null while this rule is still open. */
String disposition, String reviewedByUsername, String reviewNotes,
/** True where the officer named this rule; false where it was closed alongside one they named. */
boolean directlyDecided) implements Serializable {
}
@@ -18,15 +18,25 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlTransactionRuleRepository transactionRuleRepository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
// Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every
// lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so
// without this the screen fails outright at the very moment it has nothing to report.
if (alerts.isEmpty()) {
return List.of();
}
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
// lookup inside the map step would re-read the same catalog rows repeatedly.
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
// Same reasoning for the also-matched rules, which are held on the alert as bare codes: one
// read for every code on the page, not one per alert.
// The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole
// page at once rather than per alert.
final Map<Long, List<AmlAlertRule>> findingsByAlertId = this.alertRuleRepository
.findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream()
.collect(Collectors.groupingBy(f -> f.getAlert().getId()));
final Map<String, AmlTransactionRule> rulesByCode = rulesByCodeFor(alerts, findingsByAlertId);
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values());
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
.map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList());
}
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
.toList();
/**
* Every rule named anywhere on this page, keyed on its code.
*
* <p>
* Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the
* description behind it.
*/
private Map<String, AmlTransactionRule> rulesByCodeFor(final List<AmlAlert> alerts,
final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final Set<String> codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank())
.flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(java.util.HashSet::new));
findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add);
alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add);
if (codes.isEmpty()) {
return Map.of();
}
final Map<String, AmlTransactionRule> byCode = new HashMap<>();
this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule));
return byCode;
}
/**
* Every rule the transaction matched, governing one first, each carrying its description and severity.
*
* <p>
* A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the
* officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was
* raised - keeps its code as the description, because the alert is a record of what happened and editing the rule
* set afterwards does not change that.
*/
private List<AmlMatchedRuleData> matchedRulesOf(final AmlAlert alert, final Map<Long, Integer> riskByRiskRuleId,
final Map<String, AmlTransactionRule> rulesByCode, final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final List<AmlAlertRule> findings = findingsByAlertId.get(alert.getId());
if (findings != null && !findings.isEmpty()) {
// Governing rule first, then by code, so the pane does not reorder itself between reads.
return findings.stream()
.sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode))
.map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(),
f.isGoverning(), f.getDisposition(),
f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(),
f.isDirectlyDecided()))
.collect(Collectors.toList());
}
// No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat
// fields so the pane still shows what fired, with no per-rule disposition to report.
final List<AmlMatchedRuleData> matched = new ArrayList<>();
final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(),
governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false));
final String also = alert.getAlsoMatchedRules();
if (also == null || also.isBlank()) {
return matched;
}
for (final String raw : also.split(",")) {
final String code = raw.trim();
if (code.isEmpty()) {
continue;
}
final AmlTransactionRule rule = rulesByCode.get(code);
final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode),
sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false));
}
return matched;
}
/**
* A rule's description, or its code when the rule itself is gone.
*
* <p>
* An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code
* stands in rather than the row disappearing.
*/
private String describe(final String ruleCode, final Map<String, AmlTransactionRule> rulesByCode) {
final AmlTransactionRule rule = rulesByCode.get(ruleCode);
return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription();
}
/**
* Severity of each originating catalog rule, keyed on m_client_risk_rule.id.
*
* <p>
* Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every
* also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show.
*/
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts, final Collection<AmlTransactionRule> alsoMatched) {
final List<Long> sourceIds = Stream
.concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()),
alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId))
.filter(id -> id != null).distinct().toList();
final Map<Long, Integer> byId = new HashMap<>();
if (sourceIds.isEmpty()) {
return byId;
@@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
return byId;
}
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId, Map<String, AmlTransactionRule> rulesByCode,
Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory,
alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId));
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
@@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService {
AmlAlert whitelist(Long alertId, String notes);
AmlStrCase escalate(Long alertId, String notes);
/**
* Escalates several alerts into a single STR case.
*
* <p>
* For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert
* separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a
* pattern that only means anything taken together - and a customer reviewed again a month later would add several
* more. An STR to FIU-IND reports the activity, so the case has to hold all of it.
*
* @param alertIds
* the alerts the pattern is made of; any already covered by a case keep that case
* @return the case covering them, or null when every alert was already covered
*/
AmlStrCase escalateAsOneCase(List<Long> alertIds, String notes);
/**
* Decides individual rules within one alert, leaving the rest open.
*
* @param ruleCodes
* the rules being decided now
* @param disposition
* WHITELIST or ESCALATE, applied to each of them
*/
AmlAlert reviewRules(Long alertId, List<String> ruleCodes, String disposition, String notes);
}
@@ -19,12 +19,21 @@
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,28 +43,200 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlStrCaseAlertRepository strCaseAlertRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
@Override
public AmlAlert whitelist(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
final AmlAlert alert = loadForDisposal(alertId);
alert.whitelist(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
return alertRepository.save(alert);
}
@Override
public AmlStrCase escalate(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
// loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one.
// An STR is about the transaction, and one transaction gets one case.
final AmlAlert alert = loadForDisposal(alertId);
alert.escalate(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert));
}
private AmlStrCase openStrCase(final AmlAlert alert) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
// Recorded in the link table as well as on alert_id, so every case describes what it covers the same way -
// a single-transaction case covering one alert, a pattern case covering several.
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
return strCase;
}
@Override
public AmlStrCase escalateAsOneCase(final List<Long> alertIds, final String notes) {
if (alertIds == null || alertIds.isEmpty()) {
return null;
}
final AppUser currentUser = context.authenticatedUser();
AmlStrCase strCase = null;
for (final Long alertId : alertIds) {
final AmlAlert alert = loadForDisposal(alertId);
// An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice
// under two references, and the older case is the one an officer may already have worked on.
final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null);
if (existing != null) {
if (strCase == null) {
strCase = existing.getStrCase();
}
continue;
}
alert.escalate(currentUser, notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
if (strCase == null) {
// Opened from the first alert that needed one, which is the oldest: the case is dated and referenced
// from where the pattern starts rather than from whichever alert happened to be escalated last.
strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
}
return strCase;
}
/**
* Records a decision against individual rules within one alert.
*
* <p>
* A transaction can trip six rules where five are explained by the customer's declared business and the sixth is
* not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays
* open while any rule is undecided, because a case with an open finding is not a closed case.
*
* <p>
* Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not
* about each rule that described it. Escalating a second rule on an alert already escalated adds the finding
* without opening a duplicate case.
*/
@Override
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
final AmlAlert alert = loadForDisposal(alertId);
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
final Set<String> requested = Set.copyOf(ruleCodes);
// A finding already decided is left alone, for the same reason the alert itself is: the decision recorded
// against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing
// only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than
// silently doing nothing.
final List<AmlAlertRule> targeted = findings.stream()
.filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList();
if (targeted.isEmpty()) {
throw new IllegalArgumentException(
"No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided");
}
for (final AmlAlertRule finding : targeted) {
finding.decide(decision, context.authenticatedUser(), notes, true);
alertRuleRepository.save(finding);
}
// Re-read state across every finding, including the ones just written, to decide where the alert now stands.
final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated);
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
// Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant
// an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely
// because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One
// suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case
// that is already open.
if (anyEscalated) {
alert.escalate(context.authenticatedUser(), notes);
// The findings left open were carried into the escalation, so nothing is left dangling behind a decided
// case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were
// closed to keep the case consistent, and the record must not claim otherwise.
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false);
alertRepository.save(alert);
if (!wasEscalated) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
return alert;
}
// Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while
// one of its rules is still unexamined.
//
// Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion -
// it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never
// offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable,
// waiting on a judgement that is not the officer's to make.
if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) {
return alertRepository.save(alert);
}
alert.whitelist(context.authenticatedUser(), notes);
return alertRepository.save(alert);
}
/**
* Carries a whole-alert decision down to every finding still open beneath it.
*
* <p>
* Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings
* blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings
* already decided individually keep the decision the officer made about them.
*/
private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes,
final boolean directlyDecided) {
for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) {
if (!finding.isDecided()) {
finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided);
alertRuleRepository.save(finding);
}
}
}
/**
* Loads an alert the current officer is entitled to dispose of.
*
* <p>
* An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their
* own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up
* recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes
* it a rule: the screen hides the buttons, but the endpoint is what enforces it.
*/
private AmlAlert loadForDisposal(final Long alertId) {
final AmlAlert alert = loadAlert(alertId);
// A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged
// transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second
// officer write over it would leave the file saying whatever was submitted last, with the earlier decision and
// the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately,
// through the STR case, where the change is itself recorded.
final AmlAlertStatus status = alert.getStatus();
if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) {
throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by "
+ (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername())
+ " and cannot be decided again");
}
final AppUser current = context.authenticatedUser();
if (alert.isHeldByAnother(current)) {
throw new IllegalStateException("Alert " + alertId + " is being reviewed by "
+ alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it");
}
return alert;
}
private AmlAlert loadAlert(Long alertId) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
@@ -26,11 +26,13 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
@@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService {
public AmlCtrReport generateForMonth(YearMonth month) {
final String reportMonth = month.toString();
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
// A month can be rebuilt while its report is still a draft, and must not be once it has been submitted.
// Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash
// transaction posted after it ran belonged to that month's filing and could never be added to it - the report
// was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its
// lines with what the month now holds; a submitted report is left exactly as filed.
final Optional<AmlCtrReport> existing = ctrReportRepository.findByReportMonth(reportMonth);
if (existing.isPresent()) {
final AmlCtrReport report = existing.get();
if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) {
throw new IllegalStateException(
"CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated");
}
ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId()));
ctrReportRepository.delete(report);
ctrReportRepository.flush();
}
final LocalDate start = month.atDay(1);
@@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService {
* one month's report, or null for every generated month
*/
List<AmlCtrReportLineData> retrieveLines(Long reportId);
/**
* Reportable cash transactions that no generated report contains yet.
*
* <p>
* The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no
* report until the month is regenerated. These are those transactions, so a reportable one is never invisible.
*/
List<AmlCtrReportLineData> retrievePendingLines();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -28,6 +29,7 @@ import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,6 +70,56 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
""";
/**
* Cash transactions that a CTR must name but that no generated report contains yet.
*
* <p>
* The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs
* to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks
* to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is
* listed here as pending: shaped exactly like a generated line, with no report behind it.
*
* <p>
* Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and
* cannot be decided while the month is still running.
*/
private static final String PENDING_LINES_SQL = """
SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id,
to_char(t.transaction_date, 'YYYY-MM') AS report_month,
'SAVINGS' AS transaction_type, t.id AS transaction_id,
c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group,
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
sa.activatedon_date AS account_opened_on,
sa.closedon_date AS account_closed_on,
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
WHEN 800 THEN 'MATURED' END AS account_status,
k.ckyc_number AS ckyc_number,
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
COALESCE(c.legal_form_enum, 1) = 1 AS individual
FROM m_savings_account_transaction t
JOIN m_savings_account sa ON sa.id = t.savings_account_id
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE pt.is_cash_payment = true
AND t.is_reversed = false
AND t.transaction_type_enum IN (1, 2)
AND t.amount >= ?
AND NOT EXISTS (
SELECT 1 FROM m_aml_ctr_report_line el
WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id)
ORDER BY t.transaction_date DESC, t.amount DESC, t.id
""";
/** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
private final AmlCtrReportRepository repository;
private final JdbcTemplate jdbcTemplate;
@@ -88,18 +140,35 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual")), args.toArray());
return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray());
}
/**
* Reportable cash transactions no generated report contains yet.
*
* <p>
* Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they
* are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which
* is what makes them disappear from this list.
*/
@Override
public List<AmlCtrReportLineData> retrievePendingLines() {
return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD);
}
/** Shared by both queries, which select the same columns so a pending line renders like a filed one. */
private static final RowMapper<AmlCtrReportLineData> LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData(
rs.getObject("id") == null ? null : rs.getLong("id"),
rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"),
rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"),
rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"),
rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual"));
private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
@@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
final List<AmlAlertData> alerts = byClient.get(clientId);
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
.orElse(null);
// Reviewed at a lower count means new alerts have landed since — the customer has to
// come back, otherwise one early review would silence them permanently.
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
// A standing review holds the customer out until its window runs out, or until their behaviour has moved
// on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on
// the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back
// the day after being reviewed, for the officer to re-read the explanation they had just written.
final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size());
if (onlyNeedingReview && !needsReview) {
continue;
}
@@ -91,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
summary.riskCategory(), alerts.size(),
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
// Open means undecided, not a named status: a status this count does not list would otherwise be
// dropped from the customer's open figure while the alert still sits in the feed awaiting review.
(int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(),
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
@@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final AmlAlertRepository alertRepository;
private final AmlAlertWritePlatformService alertWritePlatformService;
@Override
@Transactional
@@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
// The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row
// and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an
// STR while the alerts sat untouched and no case ever reached the Principal Officer.
final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window);
review.recordAlertsActioned(actioned);
return this.reviewRepository.saveAndFlush(review).getId();
}
/**
* Carries the reviewer's conclusion through to the customer's open alerts.
*
* <pre>
* CLEARED every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
* ESCALATED every open alert is escalated, which opens an STR case for the Principal Officer
* MONITOR nothing is disposed of - the point of monitoring is that the alerts stay open
* </pre>
*
* Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the
* audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any
* of that here would be a second, quieter path to the same records.
*/
private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes,
final int windowDays) {
if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) {
return 0;
}
final LocalDateTime since = LocalDateTime.now().minusDays(windowDays);
final List<AmlAlert> openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since);
final String trail = "Recurring-behaviour review: " + notes;
if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) {
// One case for the whole pattern, not one per transaction. What is being reported is the customer's
// repeated activity, and the Principal Officer investigating it needs the transactions that make it up
// together - a case per alert would hand them four fragments of one story, and five more the next time
// this customer was reviewed.
this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail);
return openAlerts.size();
}
int actioned = 0;
for (final AmlAlert alert : openAlerts) {
this.alertWritePlatformService.whitelist(alert.getId(), trail);
actioned++;
}
return actioned;
}
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
@@ -20,17 +20,27 @@ package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.data.AmlDirection;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -51,9 +61,39 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
/**
* Rules that carry a filing obligation in their own right, whatever else the transaction also
* matched.
*
* <p>
* A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or
* above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a
* behavioural rule outrank it would bury a statutory filing behind an internal observation.
*/
private static final Set<String> STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L",
"STRUCTURED_CTR_10L_MONTHLY");
/**
* Which rule an alert is raised under when a transaction matches several.
*
* <p>
* Statutory reporting first, because those must be filed. Then the largest threshold, which is
* the system's own measure of how far out of the ordinary the transaction is - a rule written
* for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last,
* so the same transaction always reports under the same rule rather than shifting with whatever
* order the database returned.
*/
private static final Comparator<AmlTransactionRule> RULE_PRECEDENCE = Comparator
.comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1)
.thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(),
Comparator.reverseOrder())
.thenComparing(AmlTransactionRule::getRuleCode);
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final ClientKycRepository clientKycRepository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
private final AmlSubjectScopeResolver subjectScopeResolver;
@@ -63,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
final List<AmlAlert> raised = new ArrayList<>();
final List<AmlTransactionRule> matched = new ArrayList<>();
for (final AmlTransactionRule rule : candidates) {
if (isHit(rule, context)) {
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
raised.add(alertRepository.save(alert));
matched.add(rule);
}
}
final List<AmlAlert> raised = new ArrayList<>();
if (!matched.isEmpty()) {
// One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000
// cash deposit arrived as two open alerts - the CTR and the new-account rule - and the
// officer had to dispose of each. Nothing stopped them whitelisting one while
// escalating the other, leaving the same deposit recorded as both cleared and
// suspicious. A monitoring system reports a transaction once, under the rule that most
// warrants attention.
matched.sort(RULE_PRECEDENCE);
final AmlTransactionRule governing = matched.get(0);
// When the alert was raised, not the transaction's value date. The context carries the date the
// transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read
// "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it
// noticed something; the value date is still on the transaction the alert points at.
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(),
governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant());
// The rules that did not govern are still recorded: an analyst needs to see that the
// deposit was both reportable and on a new account, even though it is one case.
if (matched.size() > 1) {
alert.recordAlsoMatchedRules(
matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", ")));
}
// The worst band across everything that matched, not only the rule the alert was raised
// under. A statutory CTR outranks a high-severity behavioural rule when the governing
// rule is picked, so without this the screen would see "CTR, no severity" and offer the
// transaction up for a batch decision alongside ordinary low-value alerts.
final Map<Long, Integer> bandBySourceId = severityBySourceRuleId(matched);
alert.recordHighestSeverity(highestSeverityOf(bandBySourceId));
final AmlAlert saved = alertRepository.save(alert);
// One finding per rule, so each can be disposed of on its own. Severity is copied in
// rather than read back through the rule later: the alert records what was detected at
// the time, and re-banding a rule afterwards must not rewrite that.
for (final AmlTransactionRule rule : matched) {
final Long sourceId = rule.getSourceClientRiskRuleId();
alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(),
sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing));
}
raised.add(saved);
}
// Every transaction type funnels through here, so the recurring-behaviour check is done once
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
// the batch size lets the service tell a first-time offender from a repeat one without
@@ -82,6 +162,45 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
return raised;
}
/**
* The worst severity band among the rules that matched.
*
* <p>
* A transaction rule carries no severity of its own; it inherits the risk category of the
* catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute
* nothing, because a statutory return is not a graded suspicion. Null when none of the matched
* rules carried a severity at all.
*/
private Integer highestSeverityOf(final Map<Long, Integer> bandBySourceId) {
Integer worst = null;
for (final Integer band : bandBySourceId.values()) {
if (band != null && (worst == null || band > worst)) {
worst = band;
}
}
return worst;
}
/**
* Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}.
*
* <p>
* Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written
* beneath it - rather than querying the catalog twice for the same rows.
*/
private Map<Long, Integer> severityBySourceRuleId(final List<AmlTransactionRule> matched) {
final List<Long> sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null)
.distinct().toList();
if (sourceIds.isEmpty()) {
return Map.of();
}
final Map<Long, Integer> bandById = new HashMap<>();
for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) {
bandById.put(catalogRule.getId(), catalogRule.getRiskCategory());
}
return bandById;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (!appliesToThisTransaction(rule, context)) {
return false;
@@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
private final AmlStrCaseRepository strCaseRepository;
private final AmlStrCaseAlertRepository strCaseAlertRepository;
private final AmlAlertRepository alertRepository;
private final AppUserRepository appUserRepository;
private final PlatformSecurityContext context;
private final StrSlaCalculator strSlaCalculator;
@@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
: null;
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
return strCaseRepository.save(strCase);
final AmlStrCase saved = strCaseRepository.save(strCase);
// The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded
// clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have
// decided one thing and the screen an officer works from would say another.
final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS;
final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear")
+ (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes);
for (final AmlAlert alert : alertsOf(saved)) {
alert.applyStrConclusion(suspicious, principalOfficer, note);
alertRepository.save(alert);
}
return saved;
}
/**
* Every alert the case covers.
*
* <p>
* Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and
* concluding it has to answer all of them rather than only the one whose id happens to sit on the case.
*/
private List<AmlAlert> alertsOf(final AmlStrCase strCase) {
final List<AmlAlert> covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream()
.map(AmlStrCaseAlert::getAlert).collect(Collectors.toList());
// Cases opened before the link table existed carry only alert_id.
if (covered.isEmpty() && strCase.getAlert() != null) {
covered.add(strCase.getAlert());
}
return covered;
}
@Override
@@ -0,0 +1,46 @@
/**
* 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.aml.service;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so
* raising or lowering one is a decision about how closely the bank watches that customer. It is
* raised from the Risk Review Report - which already works out where a rating ought to sit - and
* takes effect only when a checker agrees.
*/
public interface ClientRiskChangeRequestService {
/** Raised by the reviewer. Nothing moves until a checker approves. */
CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason);
/** Applies the new category to the customer's KYC record and records the change in history. */
CommandProcessingResult approve(Long requestId);
CommandProcessingResult reject(Long requestId, String rejectionReason);
/** @param status PENDING / APPROVED / REJECTED, or null for all */
List<Map<String, Object>> retrieveAll(String status);
}
@@ -0,0 +1,241 @@
/**
* 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.aml.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_APPROVED = "APPROVED";
private static final String STATUS_REJECTED = "REJECTED";
/** KycRiskCategory: 1 Low, 2 Medium, 3 High. */
private static final int LOWEST_CATEGORY = 1;
private static final int HIGHEST_CATEGORY = 3;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final ClientKycRepositoryWrapper kycRepositoryWrapper;
private final ClientRiskCategoryHistoryRepository historyRepository;
@Override
@Transactional
public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) {
final AppUser currentUser = this.context.authenticatedUser();
if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) {
throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3).");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("A reason is required to change a customer's risk category.");
}
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
final Integer currentCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(currentCategory)) {
throw new IllegalStateException("The customer is already on that risk category.");
}
if (!this.jdbcTemplate
.queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId)
.isEmpty()) {
throw new IllegalStateException(
"A risk category change is already pending for this customer. Wait for it to be approved or rejected.");
}
final KeyHolder kh = new GeneratedKeyHolder();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(con -> {
final java.sql.PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_client_risk_change_request "
+ "(client_id, current_category, requested_category, reason, status, maker_id, created_date) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, client.getId());
if (currentCategory == null) {
ps.setNull(2, java.sql.Types.INTEGER);
} else {
ps.setInt(2, currentCategory);
}
ps.setInt(3, requestedCategory);
ps.setString(4, reason);
ps.setString(5, STATUS_PENDING);
ps.setLong(6, currentUser.getId());
ps.setObject(7, now);
return ps;
}, kh);
return new CommandProcessingResultBuilder().withClientId(client.getId())
.withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
}
@Override
@Transactional
public CommandProcessingResult approve(final Long requestId) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot approve your own risk category change.");
}
final Long clientId = ((Number) request.get("client_id")).longValue();
final Integer requestedCategory = ((Number) request.get("requested_category")).intValue();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
// Read again rather than trusted from the request: the rating may have moved since - an AML
// auto-escalation on the customer's third STR case does exactly that - and the history has
// to record where it actually moved from.
final Integer previousCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(previousCategory)) {
throw new IllegalStateException(
"The customer is already on the requested risk category; the rating moved after this request was raised.");
}
// Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher
// is called back in sooner, which is most of what the rating is for.
kyc.escalateRiskCategory(requestedCategory);
this.kycRepositoryWrapper.saveAndFlush(kyc);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory,
(String) request.get("reason"), currentUser, now));
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
STATUS_APPROVED, currentUser.getId(), now, requestId);
return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build();
}
@Override
@Transactional
public CommandProcessingResult reject(final Long requestId, final String rejectionReason) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot reject your own risk category change.");
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? "
+ "WHERE id = ?",
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId);
return new CommandProcessingResultBuilder().withEntityId(requestId).build();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
final StringBuilder sql = new StringBuilder("""
SELECT r.id AS "id",
r.client_id AS "clientId",
c.display_name AS "clientName",
c.account_no AS "cifNumber",
o.name AS "officeName",
r.current_category AS "currentCategory",
r.requested_category AS "requestedCategory",
r.reason AS "reason",
r.status AS "status",
maker.username AS "makerUsername",
to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate",
checker.username AS "checkerUsername",
to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate",
r.rejection_reason AS "rejectionReason"
FROM m_client_risk_change_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_office o ON o.id = c.office_id
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null && !status.isBlank()) {
sql.append(" AND r.status = ? ");
args.add(status);
}
sql.append(" ORDER BY r.id DESC ");
return this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
}
private Map<String, Object> findRequestOrThrow(final Long requestId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Risk category change request not found.");
}
return rows.get(0);
}
/**
* The same checker rule the rest of the AML module uses: a rating change is a compliance
* decision, so it is approved by a manager rather than by whoever happens to be at the screen.
*/
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("principal officer") || n.contains("compliance"));
if (!isChecker) {
throw new IllegalStateException(
"Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change.");
}
}
}
@@ -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
@@ -38,12 +38,16 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -67,10 +71,26 @@ import org.springframework.transaction.annotation.Transactional;
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
/**
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
* own office. A person moved to another branch's teller keeps what they took at the old branch
* there.
*/
private static final String COUNTER_OFFICE_OF_STD = """
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
LIMIT 1),
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
""";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
private final SavingsInterestPostingReportService interestPostingReportService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -189,8 +209,8 @@ public class BranchDayRegisterApiResource {
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
// cross-check was being compared against every branch's counted cash at once, which makes it
// wrong at every office but the first. A row whose officer maps to no cashier is still
// counted, for the same reason as the withdrawal legs in bookBreakdown.
// wrong at every office but the first. A row whose officer has no cashier allocation counts
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
//
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
@@ -198,24 +218,16 @@ public class BranchDayRegisterApiResource {
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.REAL_TXN_ID + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
@@ -513,8 +525,7 @@ public class BranchDayRegisterApiResource {
final Long contraId = contraIds.get(0);
final String currencyCode = jdbcTemplate.queryForObject(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
+ System.nanoTime();
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION);
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
@@ -588,6 +599,32 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " is already closed.");
}
// ── This branch's savings interest reports must be approved before it closes ──
// Not overridable by a force-close reason: the branch posts its own customers' interest when
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!interestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
"Cannot close the day — this branch's savings interest posting report is not approved for: "
+ String.join(", ", interestPending)
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
}
// ── The head office closes last ──
// Every branch that opened the day closes first and posts its own interest; a branch that
// never opened is not waited for.
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
if (isHeadOffice) {
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
// still working and has not posted its interest.
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
if (!stillOpen.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
+ ". The head office closes last.");
}
}
// ── Ensure every cashier holding cash has been reconciled AND settled ──
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
// and if so, verify reconciliation and settlement records exist for the business date.
@@ -686,6 +723,28 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
// ── The branch posts its own approved savings interest as it closes ──
// The head office also posts what it approved for branches that did not open today. Entries
// are raised in the customers' own branch either way.
//
// Posted here, before the close is written, and synchronously, so the outcome is never
// silent: each report posts in a transaction of its own and commits, and if any fails the
// close is refused with the reason — the day stays open, the report stays APPROVED, and
// closing again once the cause is fixed posts it.
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!toPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
if (!interestFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
@@ -1003,6 +1062,9 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
// The close screen fetches this office's tellers with it; left out, it fetched every teller
// the user can see, and the head office counted each branch's vault as its own.
m.put("officeId", r.get("office_id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
@@ -1056,13 +1118,34 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
// By the counter that took the cash. A savings deposit carries no office_id, and the old
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
AND COALESCE(counter_office_id, office_id) = ?
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
// it was missing from this total entirely, understating expected cash by exactly what a
// walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
// over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
// fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
// whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
// this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
// are correctly excluded by the payment method filter.
final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
FROM m_sponsor_dd_issue_request dd
WHERE dd.status = 'APPROVED'
AND dd.payment_method = 'CASH'
AND dd.issued_date = ?
AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
""", BigDecimal.class, date, officeId));
final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
@@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource {
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
// What the counter says this GL credit is actually for — a code value under the
// "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row
// is one of the flows below picks up its own money later (Share Advance Collection, Sponsor
// DD), in which case that adoption checks this tag so it can never take a voucher meant for
// the other one. See 0565_convert_entry_type_to_code_value.xml.
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
// retyping face value/charges once this request is approved and selected as cash evidence.
@@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
);
// Globally unique voucher number from the central m_voucher register
@@ -186,7 +192,7 @@ public class CashierDepositRequestApiResource {
// reject() will delete these records so the vault position is reversed.
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
@@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource {
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount"
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id"
+ " FROM m_cashier_deposit_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource {
payment_type_name, description, txn_date, status,
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -358,6 +364,17 @@ public class CashierDepositRequestApiResource {
updatedLog, Timestamp.from(Instant.now()), id
);
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
// means the cashier took the cash again, so the notes are recorded again -- without this an
// approved resubmission credited the account while the drawer never received its notes.
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
if (denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
(String) record.get("account_no"), toLong(record.get("client_id")),
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
}
// Keep the central voucher register in sync with the edited amount
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
@@ -412,13 +429,26 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true);
} else {
// Determine real account type by checking which table owns this ID.
// Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix).
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
// The account_no is also checked -- m_current_account and m_savings_account each keep
// their own identity sequence, so the same numeric id can legitimately belong to a
// current account AND an unrelated savings account at once. An id match alone once sent
// a real deposit into a stranger's current account instead of the savings account the
// request actually named; account_no is the one value that can't collide between them.
final String requestAccountNo = (String) record.get("account_no");
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -565,7 +595,8 @@ public class CashierDepositRequestApiResource {
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
final String requestAccountNo = (String) record.get("account_no");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
}
@@ -624,7 +655,12 @@ public class CashierDepositRequestApiResource {
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
} else {
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
// The request's own account_no is normally right, but if a cashier ever searched by a
// short/partial account number this is where that would surface -- prefer the number
// looked up fresh from savingsAccountId, the id actually posted against.
final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
realAccountNo != null ? realAccountNo : accountNo, amount);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
@@ -634,6 +670,19 @@ public class CashierDepositRequestApiResource {
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
/** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
* always querying {@code m_savings_account} would silently return an unrelated savings account's
* number whenever the two id spaces happened to collide. Every other non-GL type here is a
* {@code m_savings_account} row. */
private String savingsAccountNoOf(final String accountType, final Long accountId) {
if (accountId == null) {
return null;
}
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
.findFirst().orElse(null);
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
@@ -642,6 +691,20 @@ public class CashierDepositRequestApiResource {
.orElse(Map.of());
}
/**
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
*/
private Long counterOfficeId(final AppUser user) {
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
: jdbcTemplate.queryForList("""
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.staff_id = ? LIMIT 1
""", Long.class, user.getStaff().getId());
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -651,7 +714,7 @@ public class CashierDepositRequestApiResource {
txn_date, status, cashier_id, cashier_name, cashier_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
FROM m_cashier_deposit_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -694,6 +757,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -774,17 +895,65 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
private boolean isCurrentAccountById(final Long accountId) {
/**
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
*
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
* this returned true, even though the request was for m_savings_account id=1.
*
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
* only trusted once the account_no on that current-account row confirms it is truly the same
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
* has no account_no to compare against, e.g. an id already known to be unambiguous).
*/
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
try {
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
return count != null && count > 0;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
Integer.class, accountId, expectedAccountNo);
return count != null && count > 0;
} catch (Exception e) {
return false;
}
}
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*
* expectedAccountNo guards the current-account check against an id collision with
* m_savings_account — see {@link #isCurrentAccountById}.
*/
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId, expectedAccountNo)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) {
@@ -860,9 +1029,14 @@ public class CashierDepositRequestApiResource {
*/
// Called at submit time — uses request id as temporary txn_id.
// approve() updates txn_id to real Fineract id; reject() deletes these records.
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private void savePendingDenomination(final String denominationData,
final Long requestId, final Long savingsAccountId, final String accountNo,
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
final String txnDate) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
@@ -893,10 +1067,14 @@ public class CashierDepositRequestApiResource {
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
""",
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
// Dated on the request's own date, which is what the branch day's book figure reads.
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
// drawer on another and leave both days' cash wrong.
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
cashierId, cashierUsername, paymentTypeName
);
}
@@ -1014,6 +1192,18 @@ public class CashierDepositRequestApiResource {
}
}
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
private Long resolveEntryTypeCvId(final Long provided) {
if (provided != null) {
return provided;
}
return jdbcTemplate.queryForObject(
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
Long.class);
}
private String buildActivityLog(final String existingJson, final String action,
final String username, final String note) {
JsonArray log = new JsonArray();
@@ -90,10 +90,10 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Out: settlements to vault
@@ -120,10 +120,10 @@ public class CashReconciliationApiResource {
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
@@ -60,34 +60,11 @@ public class CashReportApiResource {
* </ul>
*
* <p>
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
* reads the same on all four.
* The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW},
* shared with the day register, the reconciliation screen and the withdrawal stock check, so a
* drawer reads the same on all four.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
+ "AND " + CountedCashLedger.REAL_TXN_ID
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
// such transactions in this database were carrying ₹1.68 lakh that never existed.
//
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
// everything counted after it) out of the cashier's position.
//
// Compared per transaction inside a correlated subquery rather than as a join, so a
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
// CountedCashLedger's rule above.
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction mt "
+ " WHERE mt.id = std.txn_id"
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW;
/**
* Day book — every voucher raised on one date, in one list, newest first.
@@ -499,86 +476,52 @@ public class CashReportApiResource {
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Today's own movement -- what actually crossed the counter, the vault hatch or the
// settlement window on this one date. Unchanged from before: this is informational, "what
// happened today", and stays that way regardless of what the drawer carried in from an
// earlier, unsettled day.
final Map<Long, Map<String, Object>> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId);
// Every note and coin that reached the drawer or left it, per denomination.
//
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
// counted against. So the vault allocation and the settlement back are included on the same
// footing as the counter work, and each row says where its notes came from.
//
// Cashier scoping differs per leg because each table records the party differently: the
// counter tables name the acting user, the cash tables name the cashier row.
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
// What the drawer already held before today started -- everything ever recorded up to (but
// not including) this date, net. A cashier who force-closed yesterday without settling, then
// settled today, hands back notes that arrived on several different days; comparing that
// settlement only against today's own vault-in/deposit legs manufactured an impossible
// negative count for every denomination the earlier days had contributed. Cumulative "as of
// yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use
// for a drawer's running position, applied here as this report's opening balance.
final Map<Long, Map<String, Object>> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId);
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
// Counter work: what customers handed over and were paid.
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
// Drawn from the vault in the morning.
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
// Handed back to the vault at close.
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
final Map<Long, Map<String, Object>> merged = new LinkedHashMap<>(todayLegs);
for (final Long denomId : openingLegs.keySet()) {
merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id)));
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
final long vaultInCount = rs.getLong("vault_in_count");
final long depositCount = rs.getLong("deposit_count");
final long withdrawalCount = rs.getLong("withdrawal_count");
final long settledOutCount = rs.getLong("settled_out_count");
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> today : merged.values()) {
final Long denomId = (Long) today.get("denominationId");
final Map<String, Object> opening = openingLegs.getOrDefault(denomId, null);
final long openingCount = opening == null ? 0L
: (long) opening.get("vaultInCount") + (long) opening.get("depositCount")
- (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount");
final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO
: ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount"))
.subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount"));
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
final long vaultInCount = (long) today.get("vaultInCount");
final long depositCount = (long) today.get("depositCount");
final long withdrawalCount = (long) today.get("withdrawalCount");
final long settledOutCount = (long) today.get("settledOutCount");
final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount");
final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount");
final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount");
final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", denomId);
row.put("denominationValue", today.get("denominationValue"));
row.put("kind", today.get("kind"));
row.put("openingCount", openingCount);
row.put("openingAmount", openingAmount);
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount);
@@ -587,13 +530,18 @@ public class CashReportApiResource {
row.put("withdrawalAmount", withdrawalAmount);
row.put("settledOutCount", settledOutCount);
row.put("settledOutAmount", settledOutAmount);
// What is left of this denomination: everything that came in, less everything that went
// out. This is the figure a teller counts their notes against, per note.
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount",
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
return row;
}, args.toArray());
// What is left of this denomination: whatever the drawer already held, plus what came in
// today, less what went out today. This is the figure a teller counts their notes
// against, per note.
row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount)
.subtract(settledOutAmount));
rows.add(row);
}
rows.sort((a, b) -> {
final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue"));
return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind")));
});
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -723,25 +671,25 @@ public class CashReportApiResource {
// day itself: taken from the vault, in over the counter, out over the counter, settled
// back. What is left is what should physically be in the drawer.
result.put("todayVaultIn", dayLegTotal(
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ct.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todaySettledOut", dayLegTotal(
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
+ " AND COALESCE(cs.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierIn", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierOut", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
@@ -762,6 +710,105 @@ public class CashReportApiResource {
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
/**
* The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per
* denomination, with {@code dateComparison} deciding which side of the target date each leg is
* read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for
* everything before it). One query shape shared by both calls in {@link #closing}, rather than
* two copies that could drift apart.
*/
private Map<Long, Map<String, Object>> fetchLegTotals(final String dateComparison, final String dateBound,
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final Map<Long, Map<String, Object>> byDenomination = new LinkedHashMap<>();
jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> row = new LinkedHashMap<>();
final Long denomId = rs.getLong("denomination_id");
row.put("denominationId", denomId);
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("vaultInCount", rs.getLong("vault_in_count"));
row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount"));
row.put("depositCount", rs.getLong("deposit_count"));
row.put("depositAmount", rs.getBigDecimal("deposit_amount"));
row.put("withdrawalCount", rs.getLong("withdrawal_count"));
row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount"));
row.put("settledOutCount", rs.getLong("settled_out_count"));
row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount"));
byDenomination.put(denomId, row);
return row;
}, args.toArray());
return byDenomination;
}
/** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination
* that carried an opening balance into this date but saw no movement of its own on it -- without
* this, that denomination would vanish from the report instead of showing its carried-forward
* count untouched. */
private Map<String, Object> emptyLegRow(final Map<String, Object> template) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", template.get("denominationId"));
row.put("denominationValue", template.get("denominationValue"));
row.put("kind", template.get("kind"));
row.put("vaultInCount", 0L);
row.put("vaultInAmount", BigDecimal.ZERO);
row.put("depositCount", 0L);
row.put("depositAmount", BigDecimal.ZERO);
row.put("withdrawalCount", 0L);
row.put("withdrawalAmount", BigDecimal.ZERO);
row.put("settledOutCount", 0L);
row.put("settledOutAmount", BigDecimal.ZERO);
return row;
}
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
@@ -1124,7 +1171,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
@@ -1150,7 +1197,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -1268,13 +1315,13 @@ public class CashReportApiResource {
if (cashierId != null) {
officerFilter = "AND std.officer_id IN ("
+ "SELECT au.id FROM m_appuser au "
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, cashierId };
} else if (tellerId != null) {
officerFilter = "AND std.officer_id IN ("
+ "SELECT au.id FROM m_appuser au "
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, tellerId };
} else {
officerFilter = "AND std.officer_id IN ("
@@ -24,6 +24,8 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -95,12 +97,42 @@ public class ShareAdvanceCollectionApiResource {
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── acc_gl_account.classification_enum ─────────────────────────────────
// Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE.
private static final int GL_CLASSIFICATION_INCOME = 4;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
/**
* Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually
* collected FOR a share collection, and nothing else.
*
* <p>
* The Entry Type the counter picks at save time is the only thing that says what a GL receipt was
* taken for, so it decides this too: a receipt left General, or tagged for some other purpose
* entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the
* adoption path was not enough -- a receipt adopted before that guard existed stayed on these
* lists for ever, which is exactly how a General voucher kept reappearing against share accounts.
*
* <p>
* A collection with no voucher of its own is the one thing this lets through: those come from the
* direct Share Advance Collection endpoint, which exists for nothing but share collections, so
* there is no counter tag to read and none needed.
*/
private static final String SHARE_COLLECTION_TAGGED_ONLY = """
AND (c.voucher_no IS NULL
OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d
JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id
WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection')
OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r
JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id
WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection'))
""";
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
@@ -150,7 +182,7 @@ public class ShareAdvanceCollectionApiResource {
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE);
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
@@ -186,26 +218,160 @@ public class ShareAdvanceCollectionApiResource {
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
@Transactional
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
// A voucher explicitly tagged Share Collection at save time no longer waits for the counter
// to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
// for it. Anything not tagged this way (an older row, or one deliberately left General) still
// needs the by-voucher search below, same as before.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final List<Map<String, Object>> rows = unappliedFor(clientId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
/**
* Unapplied receipts ready to be ticked, for one member or for everybody.
*
* Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the
* same rows under {@code own} so an Apply screen needs a single call rather than two that were
* only ever querying the same table with the same filters.
*/
private List<Map<String, Object>> unappliedFor(final Long clientId) {
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, "
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
// The GL account this specific receipt's money actually sits in -- its own posting's
// credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
// Account picker above can be kept in sync with whichever receipt gets ticked.
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "
+ SHARE_COLLECTION_TAGGED_ONLY);
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
return jdbcTemplate.queryForList(sql.toString());
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// GET /unattributed — share-collection receipts that name no member yet
// ══════════════════════════════════════════════════════════════════════
/**
* Approved cashier receipts tagged as a share collection that are still waiting to be attached to
* a member.
*
* <p>
* A receipt taken straight into a GL account names no member — a GL account is nobody's account —
* so it can never be auto-adopted onto whichever member's screen happens to be open. That rule
* stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on
* purpose and then finding nothing on the Apply screen but "no unapplied receipt for this
* member", with no way to discover that the voucher they just raised is sitting right there.
*
* <p>
* This lists exactly those receipts so they can be seen and picked. Picking one still runs the
* same by-voucher adoption a typed voucher number does: a person at the counter deliberately
* attaching a named voucher to a member they can see. The list makes the voucher findable; it
* does not decide whose money it is.
*
* <p>
* Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left
* out, so nothing here is something a second member could take twice. A receipt that already
* names a member (a clerk collection resolved from the savings account it debited) is still
* listed here, not only on that member's own screen -- every branch counter runs the same Apply
* screen for whichever member is in front of them, so a voucher must be findable there regardless
* of which member's screen happened to raise or auto-adopt it first. Attaching it from a different
* member's screen still goes through the ordinary by-voucher lookup below, which refuses a
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
* does not reassign it.
*/
/**
* @param clientId
* the member whose own Apply screen this is. Their own unapplied receipts come back
* separately under {@code own} -- the same rows, in the same shape, that
* {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and
* those rows are left out of {@code rows} so the same receipt is never listed twice on
* one screen. Omit it and everything available comes back under {@code rows}.
*/
@GET
@Path("/unattributed")
public Response listUnattributed(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
// voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen
// is opened, not only once the specific member it names happens to be looked up.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\","
+ " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount,"
+ " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\","
+ " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\""
+ " FROM m_cashier_deposit_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED'"
+ " AND d.client_id IS NULL AND d.share_account_id IS NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ " UNION ALL "
+ " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\","
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS \"glAccountNo\","
// The GL account's own name, not the member's -- "Deposited To" names an
// account on every other row of this list, and a receipt that already names a
// member still needs its own column to say whose it is (below), not to borrow
// this one and read as though a person were a GL account.
+ " (SELECT ga.name FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS \"glAccountName\","
+ " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\","
// Visibly names whose receipt this is -- listing it here does not attribute it
// (attaching it still runs the same by-voucher lookup, which refuses a
// mismatched member), but a counter deciding whether to attach it needs to see
// that up front rather than discovering it only after clicking Attach.
+ " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)"
+ " || ' (linked to ' || cl.display_name || ')' AS description,"
+ " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\""
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id"
// A voucher's own free-standing collection (raised straight through POST
// /shareadvancecollection, no cashier deposit or clerk request behind it) has
// no entry-type tag at all -- there is nothing else it could be, so it always
// qualifies. One that WAS adopted from a cashier deposit or clerk disbursement
// only stays listed here while that source row is still tagged Share
// Collection today -- a voucher whose entry type reads General (or was
// corrected to General after adoption) belongs on the ordinary voucher
// register, not on this Share Collection-only list, even though the earlier
// adoption already happened and cannot itself be undone here.
+ " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id"
+ " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id"
+ " WHERE c.status = 'UNAPPLIED'"
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
// This member's own receipts come back under "own" below, ready to tick; listing
// them here as well would put the same receipt on screen twice, once needing a
// tick and once an Attach, for no difference in what it is.
+ (clientId != null ? " AND c.client_id <> " + clientId : "")
+ " ORDER BY \"txnDate\" DESC");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
out.put("own", clientId == null ? List.of() : unappliedFor(clientId));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
@@ -229,9 +395,18 @@ public class ShareAdvanceCollectionApiResource {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
// gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen
// follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a
// receipt resolved through this search must feed that picker exactly the same as one resolved
// from the member's own list, or the picker is silently left on "Select GL account…" for
// every voucher found this way.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ " c.applied_share_account_id,"
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
@@ -271,19 +446,33 @@ public class ShareAdvanceCollectionApiResource {
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
* The GL account picker on the Apply Collection screen — narrowed to accounts that actually have
* an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now,
* so the counter is choosing from real, live money rather than the entire chart of accounts
* (which is every postable GL account in the bank, share-related or not, well over a hundred of
* them, Cash at Vault included, with nothing to say which one has anything to find).
*
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
* applying a receipt picks the account the deposit actually went to, every time.
* Still never resolved from a share product's own Share Suspense mapping -- that mapping is
* exactly what this lookup no longer trusts, since it can go missing or wrong without anyone
* noticing until a voucher search fails at the counter. This filters by what genuinely sits in
* each account today, not by what any product's configuration claims should. Falls back to the
* unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a
* dead end -- a voucher search should not become unreachable just because this narrowing has
* nothing to narrow to yet.
*/
@GET
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
// Every postable GL account, same as before this endpoint was ever narrowed to "only
// accounts that currently hold an eligible receipt" -- that narrowing meant to help the
// counter, but it back-fired the moment a genuine voucher sat in a GL account no live
// receipt query happened to match (a fee posted somewhere unusual, a receipt taken
// yesterday), silently dropping it from the list with no way to pick it by hand. The full
// list is always safe to search against; nothing here is auto-picked for the counter (see
// ngOnInit in the dialog) unless it is genuinely the only option.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
@@ -316,6 +505,47 @@ public class ShareAdvanceCollectionApiResource {
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — The actual Dr/Cr GL legs a receipt posted, for the details popup
// ══════════════════════════════════════════════════════════════════════
/**
* What this receipt actually did to the ledger — the real Dr/Cr legs under its own
* {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box
* (a different, unrelated piece of screen state that a details popup must never be shown
* against, since it can name a completely different account than the one this receipt sits in).
*/
@GET
@Path("/{collectionId}/gl-entries")
public Response glEntries(@PathParam("collectionId") final Long collectionId) {
this.context.authenticatedUser();
final List<Map<String, Object>> collRows = jdbcTemplate
.queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId);
if (collRows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found.");
}
final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id");
final List<Map<String, Object>> legs = glTransactionId == null ? List.of()
: jdbcTemplate.queryForList(
"SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount"
+ " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id",
glTransactionId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("transactionId", glTransactionId);
final List<Map<String, Object>> entries = new java.util.ArrayList<>();
for (final Map<String, Object> leg : legs) {
final Map<String, Object> e = new LinkedHashMap<>();
e.put("glCode", leg.get("gl_code"));
e.put("accountName", leg.get("account_name"));
e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr");
e.put("amount", leg.get("amount"));
entries.add(e);
}
out.put("entries", entries);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@@ -377,20 +607,10 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
}
// No client-match check here by deliberate choice: any receipt tagged Share Collection can
// be applied to any share account, regardless of which member it was originally recorded
// against. The counter is trusted to attach the right voucher by hand -- nothing here blocks
// it if they choose otherwise.
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
@@ -472,31 +692,62 @@ public class ShareAdvanceCollectionApiResource {
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
final String txnId = "SHAPP-" + System.nanoTime();
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE_APPLIED);
final String accountNo = (String) acct.get("account_no");
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
// it was never in Sundry, so nothing here should debit Sundry for it.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
// Dr whichever account each receipt's money is actually sitting in, receipt by receipt,
// including any over-payment on them — the liability those receipts created is cleared in
// full. A verified fee receipt is deliberately excluded: its money was never held for the
// shares, so nothing here should move it.
//
// Read back from the collection's own posting rather than resolved from the product's Sundry
// mapping. The cashier does not always deposit into Sundry -- an entry fee goes straight to
// its income account, and instalments taken on different days can land in different accounts
// -- so debiting the mapped Sundry account for every receipt took money out of an account
// that had never received it, and left the account that had holding the balance for ever.
// Debiting each receipt's own account is also what the clerk sees: the GL picked against the
// voucher on the apply screen is the account being cleared here.
//
// A receipt that already sits in an INCOME-classified GL is left exactly where it is, not
// debited out here at all -- an entry fee the cashier deposited straight into its own income
// account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it)
// was already recognised as income the moment it was banked. Debiting it out and crediting a
// fresh income leg below for the same rupees would recognise it a second time -- the
// duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted.
// A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy
// single-receipt case where share value and fee arrived together) still needs clearing, so it
// is debited as before and its amount still moves to Income below.
BigDecimal feeAlreadyRecognised = BigDecimal.ZERO;
for (final Map<String, Object> row : collections) {
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
final BigDecimal rowAmount = (BigDecimal) row.get("amount");
if (chargeColl == null && isIncomeGl(receiptGl)) {
feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount);
continue;
}
insertEntry(officeId, receiptGl, DEBIT, rowAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
}
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
// income account itself), so moving it again here would double it. When it was bundled into
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
// to move, exactly as before.
// Cr Income from Fees for the entry fee, but only for whatever part of it is not already
// sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already
// sits in whichever GL the cashier deposited it to, so moving it again here would double it.
// When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null)
// and genuinely never touched an income account, it still needs to move, exactly as before.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
if (chargeColl == null) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised);
if (feeStillToRecognise.compareTo(BigDecimal.ZERO) > 0) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share entry fee for " + accountNo);
}
}
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
@@ -534,17 +785,24 @@ public class ShareAdvanceCollectionApiResource {
// Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
// receipt is marked applied too, even though it posted no GL entry of its own.
//
// client_id is rewritten to this account's own client here too -- a voucher can now be found
// and applied from any member's screen, so whoever last searched it (the value it carried
// going in) is not necessarily who its money actually became share capital for. Leaving the
// old value in place would make the receipt's own record misname whose it is the moment it
// settles, which is exactly the figure a receipt-detail popup reads back afterwards.
final Object settledClientId = acct.get("client_id");
for (final Long id : collectionIds) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, id);
}
if (chargeCollectionId != null) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
+ " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, chargeCollectionId);
}
// Reveals the purchase transaction in the passbook and transactions views.
@@ -595,6 +853,102 @@ public class ShareAdvanceCollectionApiResource {
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/
/**
* Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share
* Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional
* Share screen on its own -- the point of tagging a voucher this way at save time in the first
* place.
*
* A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own --
* buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of
* these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER
* auto-adopted for any client -- it can only ever be claimed through the manual by-voucher search
* (including a deliberate click in the Unattributed list), where a person at the counter is
* actively vouching for it against a member they can see.
*
* A row that DOES carry its own client_id (a Clerk Collection, whose client comes from the
* savings account it actually debited -- real evidence, not a guess) is converted here
* unconditionally, regardless of who is asking: the point of this pass is that the voucher
* becomes visible and findable everywhere the moment it is tagged and approved, not only on the
* one screen that happens to belong to the member it names. Converting it does not hand it to
* anybody -- {@link #findByVoucher} still refuses to apply a receipt to an account whose client_id
* does not match, so the member it actually names is protected the same way whether this ran for
* their own screen or a different one.
*/
private void autoAdoptShareCollectionTagged() {
final List<Map<String, Object>> deposits = jdbcTemplate.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id,"
+ " d.cashier_id, d.cashier_name, d.description"
+ " FROM m_cashier_deposit_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ " AND d.client_id IS NOT NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
for (final Map<String, Object> dep : deposits) {
final Long resolvedClientId = toLong(dep.get("client_id"));
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
// What the cashier actually typed at save time, when they typed anything -- that is what
// the Apply Share screen's Description column exists to show. Falls back to a plain
// label only for a row nobody described, same as the manual by-voucher adoption below.
final String depDescription = (String) dep.get("description");
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
: "Share collection - Voucher " + dep.get("voucher_no");
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")),
java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"),
toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
}
final List<Map<String, Object>> collections = jdbcTemplate.queryForList(
"SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id"
+ " FROM m_clerk_disbursement_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
for (final Map<String, Object> req : collections) {
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
continue;
}
final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId);
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at");
final Object submittedAt = req.get("submitted_at");
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String voucherNo = (String) req.get("voucher_no");
final String receiptNo = nextReceiptNo();
final String reqDescription = (String) req.get("description");
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
: "Share collection - Voucher " + voucherNo;
// The real acc_gl_journal_entry.transaction_id this approval posted (see
// 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the
// voucher number only for a row approved before that column existed, since something has
// to go in a NOT NULL-shaped identity column and the voucher number at least resolves
// back to this same row.
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency,
glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note);
}
}
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
@@ -604,8 +958,10 @@ public class ShareAdvanceCollectionApiResource {
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id, d.description,"
+ " cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, shareSundryAccountNo);
@@ -618,6 +974,26 @@ public class ShareAdvanceCollectionApiResource {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// The counter tags what a GL receipt was actually collected for at save time, from the
// GLEntryType code (Admin > System > Manage Codes). Exact match required -- "General" and an
// untagged row used to be let through too, on the reasoning that untyped money carries no
// evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly
// as often share-collection cash the counter forgot to tag, and letting it through here
// quietly rewarded skipping the tag. A voucher only ever becomes reachable for a share
// collection by being tagged for one, on purpose, at save time.
final String depositEntryType = (String) dep.get("entry_type");
if (!"Share Collection".equals(depositEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as "
+ (depositEntryType == null || depositEntryType.isBlank() ? "an untagged entry" : depositEntryType)
+ ", not Share Collection. Ask the cashier to correct the entry type at Cashier › Transaction Entry, "
+ "then search again.");
}
if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
toLong(dep.get("id"))).isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been used to issue a Demand Draft.");
}
// Prefer the member recorded on the deposit; fall back to the account being settled.
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
@@ -632,18 +1008,32 @@ public class ShareAdvanceCollectionApiResource {
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// What the cashier actually typed at save time, when they typed anything -- same preference
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
// auto-adoption or by this manual by-voucher lookup. Falls back to a plain label only for a
// deposit nobody described.
final String depDescription = (String) dep.get("description");
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
: "Adopted from cashier deposit voucher " + voucherNo;
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
// adopted voucher must feed the on-screen GL Account picker exactly like one
// that was already sitting on the member's list, or the picker is silently
// left on "Select GL account…" for every voucher adopted this way.
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -665,16 +1055,30 @@ public class ShareAdvanceCollectionApiResource {
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
final String glAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request"
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
+ " ORDER BY id DESC",
"SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name,"
+ " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type"
+ " FROM m_clerk_disbursement_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " ORDER BY d.id DESC",
voucherNo, glAccountNo);
if (requests.isEmpty()) {
return null;
}
final Map<String, Object> req = requests.get(0);
// Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL
// account the clerk picked by hand, so it carries the identical ambiguity, and the identical
// exact-match requirement: a voucher only becomes reachable for a share collection by being
// tagged Share Collection at save time, "General" and untagged included.
final String reqEntryType = (String) req.get("entry_type");
if (!"Share Collection".equals(reqEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as "
+ (reqEntryType == null || reqEntryType.isBlank() ? "an untagged entry" : reqEntryType)
+ ", not Share Collection. Ask the clerk to correct the entry type on this request, then search again.");
}
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
if (savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
@@ -692,16 +1096,27 @@ public class ShareAdvanceCollectionApiResource {
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
final String reqDescription = (String) req.get("description");
final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription
: "Adopted from clerk disbursement voucher " + voucherNo;
final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo;
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId,
toLong(req.get("clerk_id")), req.get("clerk_name"), note);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id,"
// Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly
// adopted voucher must feed the on-screen GL Account picker exactly like one
// that was already sitting on the member's list, or the picker is silently
// left on "Select GL account…" for every voucher adopted this way.
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -809,6 +1224,55 @@ public class ShareAdvanceCollectionApiResource {
}
}
/**
* The account a receipt's money is actually sitting in, read back from the posting that put it
* there — the credit leg of the collection's own journal transaction.
*
* <p>
* Whoever took the money decided where it went: this register's own collection credits Sundry, a
* cashier deposit credits whichever account the cashier posted against, and an entry fee usually
* goes straight to fee income. Reading the account back is therefore the only way to know it, and
* it matches what the clerk picks against the voucher on the apply screen.
*
* <p>
* Falls back to the product's mapped Sundry account for a collection with no posting recorded
* against it, which is where anything taken before this register started stamping its transaction
* id was held.
*
* <p>
* Scoped to the live credit leg, newest first: a transaction id is only as unique as whatever
* minted it, and picking the most recent match keeps this correct even if one is ever reused.
*/
private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) {
if (glTransactionId == null || glTransactionId.isBlank()) {
return fallbackGlId;
}
final List<Long> rows = jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY id DESC LIMIT 1
""", Long.class, glTransactionId, CREDIT);
return rows.isEmpty() ? fallbackGlId : rows.get(0);
}
/**
* Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum}
* for the "Income" account type on {@code acc_gl_account}).
*
* <p>
* Used to tell an entry-fee receipt that already sits recognised as income (deposited straight
* into its own income GL by the cashier) apart from one still sitting in a holding account like
* Share Sundry — only the latter still needs moving into income here.
*/
private boolean isIncomeGl(final Long glAccountId) {
if (glAccountId == null) {
return false;
}
final Integer classification = jdbcTemplate.queryForObject(
"SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
return classification != null && classification == GL_CLASSIFICATION_INCOME;
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
@@ -1,439 +0,0 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Cashier Share Collection API — POST /v1/cashier/sharecollection
*
* Records cash received from a member for share purchase and posts GL entries:
* Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount
* Cr SHARES_SUSPENSE (Share Sundry) = shareAmount
* Cr INCOME_FROM_FEES = chargeAmount (if > 0)
*
* On account activation Fineract posts:
* Dr SHARES_SUSPENSE → Cr SHARES_EQUITY
* Activation skips charge GL if is_paid_derived = true (set here).
*/
@Path("/v1/cashier/sharecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr)
// Opposite of intuition — verified from Fineract AccountingProcessorHelper.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
// ── acc_gl_journal_entry.entity_type_enum ─────────────────────────────
// Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt()
// which only handles values 1-5 and returns null for unknown values.
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
// ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_SUBMITTED_PENDING = 100;
// ── Maximum days a collection record is considered valid ──────────────
private static final int MAX_COLLECTION_AGE_DAYS = 30;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Collect cash for a share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
// ── Parse request ──────────────────────────────────────────────────
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.request.empty",
"Request body is required.");
}
final JsonObject json;
try {
json = JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.request.invalid",
"Request body must be valid JSON.");
}
final Long shareAccountId = getLong(json, "shareAccountId");
final String transactionDateStr = getString(json, "transactionDate");
final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount");
final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount");
final String note = sanitiseNote(getString(json, "note"));
// ── Validate inputs ────────────────────────────────────────────────
if (shareAccountId == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.accountid.required",
"shareAccountId is required.");
}
if (shareAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.shareamount.negative",
"shareAmount cannot be negative.");
}
final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0
? chargeAmountRaw : BigDecimal.ZERO;
final BigDecimal totalAmount = shareAmount.add(safeCharge);
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.total.zero",
"Total collection amount must be greater than zero.");
}
final LocalDate entryDate;
try {
entryDate = transactionDateStr != null
? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr);
}
// ── Load account + client details ──────────────────────────────────
final Map<String, Object> acct = loadAccount(shareAccountId);
// ── Account must be in 'Submitted and pending approval' ───────────
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_SUBMITTED_PENDING) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.account.invalid.status",
"Cash collection is only allowed for accounts in 'Submitted and pending approval' "
+ "status. Account " + acct.get("account_no") + " has status code " + statusEnum + ".");
}
// ── Prevent double collection ──────────────────────────────────────
if (Boolean.TRUE.equals(acct.get("cashier_collected"))) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.already.collected",
"Cash has already been collected for share account "
+ acct.get("account_no") + ". Re-collection is not allowed.");
}
final Long officeId = toLong(acct.get("office_id"));
final Long productId = toLong(acct.get("product_id"));
final String currencyCode = (String) acct.get("currency_code");
final String clientName = (String) acct.get("client_name");
final String accountNo = (String) acct.get("account_no");
// ── Resolve GL accounts from product mapping ───────────────────────
final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE);
final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE);
final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES);
if (glReference == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.reference.notmapped",
"Share Reference (Cash at Vault) GL is not mapped for product " + productId
+ ". Configure Share Product accounting mappings first.");
}
if (glSuspense == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.suspense.notmapped",
"Share Suspense GL is not mapped for product " + productId
+ ". Configure Share Product accounting mappings first.");
}
if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.gl.income.notmapped",
"Income from Fees GL is not mapped for product " + productId
+ " but charge amount " + safeCharge + " is present. "
+ "Configure Share Product accounting mappings first.");
}
// ── Generate unique transaction ID ─────────────────────────────────
final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
final long userId = currentUser.getId();
final String collectorName = currentUser.getUsername();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
// ── Post GL journal entries ────────────────────────────────────────
// Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
insertEntry(officeId, glReference, DEBIT, totalAmount, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Share collection received for " + accountNo
+ (note != null ? " | " + note : ""));
// Cr Share Sundry (SHARES_SUSPENSE) = shareAmount
insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Share amount held in suspense - " + accountNo);
// Cr Income from Fees = chargeAmount (only if > 0)
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId,
entryDate, shareAccountId, userId, now, currencyCode,
"Charge collected upfront for " + accountNo);
// Mark charges paid → activation will skip duplicate charge GL entry
jdbcTemplate.update(
"UPDATE m_share_account_charge "
+ "SET is_paid_derived = true, "
+ " amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 "
+ "WHERE account_id = ? AND is_active = true AND is_paid_derived = false",
shareAccountId);
}
// ── Flip cashier_collected flag ────────────────────────────────────
jdbcTemplate.update(
"UPDATE m_share_account SET cashier_collected = true WHERE id = ?",
shareAccountId);
// ── Insert audit / validation record ──────────────────────────────
jdbcTemplate.update(
"INSERT INTO m_share_cashier_collection "
+ "(share_account_id, collected_by_user, collected_by_name, collection_date, "
+ " share_amount, charge_amount, total_amount, gl_transaction_id, "
+ " office_id, currency_code, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
shareAccountId, userId, collectorName,
java.sql.Date.valueOf(entryDate),
shareAmount, safeCharge, totalAmount,
txnId, officeId, currencyCode, note);
// ── Build GL entries summary for response ──────────────────────────
final List<Map<String, Object>> glEntries = new ArrayList<>();
glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode));
glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode));
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode));
}
// ── Response ───────────────────────────────────────────────────────
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("transactionId", txnId);
resp.put("shareAccountId", shareAccountId);
resp.put("accountNo", accountNo);
resp.put("clientName", clientName);
resp.put("collectionDate", entryDate.toString());
resp.put("collectedBy", collectorName);
resp.put("shareAmount", shareAmount);
resp.put("chargeAmount", safeCharge);
resp.put("totalAmount", totalAmount);
resp.put("currencyCode", currencyCode);
resp.put("glEntries", glEntries);
return Response.ok(gson.toJson(resp)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Check collection status for a share account
// ══════════════════════════════════════════════════════════════════════
@GET
@Path("/{shareAccountId}")
public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
try {
final Map<String, Object> rec = jdbcTemplate.queryForMap(
"SELECT scc.share_account_id, sa.account_no, "
+ " mc.display_name AS client_name, "
+ " scc.collected_by_name, scc.collection_date, "
+ " scc.share_amount, scc.charge_amount, scc.total_amount, "
+ " scc.gl_transaction_id, scc.currency_code, scc.note "
+ "FROM m_share_cashier_collection scc "
+ "JOIN m_share_account sa ON sa.id = scc.share_account_id "
+ "JOIN m_client mc ON mc.id = sa.client_id "
+ "WHERE scc.share_account_id = ?",
shareAccountId);
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("collected", true);
resp.put("shareAccountId", shareAccountId);
resp.put("accountNo", rec.get("account_no"));
resp.put("clientName", rec.get("client_name"));
resp.put("collectedBy", rec.get("collected_by_name"));
resp.put("collectionDate", rec.get("collection_date") != null
? rec.get("collection_date").toString() : null);
resp.put("shareAmount", rec.get("share_amount"));
resp.put("chargeAmount", rec.get("charge_amount"));
resp.put("totalAmount", rec.get("total_amount"));
resp.put("transactionId", rec.get("gl_transaction_id"));
resp.put("currencyCode", rec.get("currency_code"));
resp.put("note", rec.get("note"));
return Response.ok(gson.toJson(resp)).build();
} catch (EmptyResultDataAccessException e) {
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("collected", false);
resp.put("shareAccountId", shareAccountId);
return Response.ok(gson.toJson(resp)).build();
}
}
// ══════════════════════════════════════════════════════════════════════
// Private helpers
// ══════════════════════════════════════════════════════════════════════
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap(
"SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, "
+ " sa.cashier_collected, sa.currency_code, "
+ " mc.office_id, mc.display_name AS client_name "
+ "FROM m_share_account sa "
+ "JOIN m_client mc ON mc.id = sa.client_id "
+ "WHERE sa.id = ?",
shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.account.notfound",
"Share account with id " + shareAccountId + " does not exist.");
}
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ "WHERE product_id = ? AND product_type = ? AND financial_account_type = ?",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
// covers both EmptyResultDataAccessException (0 rows) and multiple rows
return null;
}
}
private String resolveGlName(final Long glAccountId) {
if (glAccountId == null) return "Unknown GL";
try {
return jdbcTemplate.queryForObject(
"SELECT name FROM acc_gl_account WHERE id = ?",
String.class, glAccountId);
} catch (EmptyResultDataAccessException e) {
return "GL-" + glAccountId;
}
}
private void insertEntry(final Long officeId, final Long glAccountId,
final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId,
final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry "
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, "
+ " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId,
java.sql.Date.valueOf(entryDate), typeEnum, amount, description,
shareAccountId, // entity_id — links entry to the share account
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
toUtc(now),
toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private Map<String, Object> glEntryMap(final String account, final String type,
final BigDecimal amount, final String currency) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("account", account);
m.put("type", type);
m.put("amount", amount);
m.put("currency", currency);
return m;
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) return null;
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number ? ((Number) o).intValue() : 0;
}
private Long getLong(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String key,
final String fieldLabel) {
if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO;
try {
return json.get(key).getAsBigDecimal();
} catch (NumberFormatException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.field.invalid.number",
"Field '" + fieldLabel + "' must be a valid number.");
}
}
private String getString(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
}
@@ -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);
@@ -340,13 +341,14 @@ public class ChequeBookIssuanceChargeService {
private Long getCurrentControlGlId(final Long currentAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/** 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,18 +385,34 @@ 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.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " 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));
}
}
}
@@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService {
if ("CURRENT".equalsIgnoreCase(accountType)) {
return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId);
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId);
}
return null;
}
@@ -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,13 +192,26 @@ 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.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " 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));
}
}
}

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