Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 aceea89c5c fix(tenants): keep the 'system' username when provisioning a new tenant
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Problem
Commit 3d3ce92e9 personalised the three users seeded into a brand-new
tenant database and renamed user id 2 from 'system' to
'<tenant>_system' (for example 'default_system'). Fineract looks this
user up by the fixed name AppUserConstants.SYSTEM_USER_NAME ('system')
through AppUserRepositoryWrapper.fetchSystemUser(), so on every fresh
tenant:
- every scheduler job failed once a minute with
  "UserNotFoundException: User with name 'system' does not exist"
  (seen with the Send Asynchronous Events job);
- COB initialisation, savings escheat, current account closure and the
  stuck-job listener would fail in the same way.
Existing tenants were not affected because the provisioner only runs
right after the very first migration of a new tenant.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:55:18 +05:30
niteshandClaude Sonnet 5 1fd94615a9 revert(fixed-deposits): remove FD maturity approval report
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A fixed deposit's maturity is settled directly by the nightly
updateMaturityDetails job again, unconditionally, for every deposit
type and every closure instruction — the approval gate this introduced
turned out to risk more than it protected and is being removed before
it ever shipped enabled by default.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:29:26 +05:30
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
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
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
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
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
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
131 changed files with 10959 additions and 975 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ gitVersioning.apply {
}
}
rev {
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
version = '1.15.0-SNAPSHOT'
}
}
@@ -477,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);
@@ -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.
*
@@ -756,6 +756,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 +892,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 +3462,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 +4809,36 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/originators/" + originatorId;
return this;
}
public CommandWrapperBuilder createSurvey() {
this.actionName = "CREATE";
this.entityName = "SURVEY";
this.entityId = null;
this.href = "/surveys";
return this;
}
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
this.actionName = "UPDATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
this.actionName = "ACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
this.actionName = "DEACTIVATE";
this.entityName = "SURVEY";
this.entityId = surveyId;
this.href = "/surveys/" + surveyId;
return this;
}
}
@@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants {
public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days";
public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting";
public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end";
public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval";
public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month";
public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group";
public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group";
@@ -69,6 +69,12 @@ public interface ConfigurationDomainService {
boolean isSavingsInterestPostingAtCurrentPeriodEnd();
/**
* Whether fixed deposit interest is reported and approved before it is posted, instead of being
* posted directly by the nightly job.
*/
boolean isFixedDepositInterestPostingApprovalEnabled();
Integer retrieveFinancialYearBeginningMonth();
Integer retrieveMinAllowedClientsInGroup();
@@ -43,6 +43,8 @@ public enum JobName {
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -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();
}
}
@@ -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();
@@ -212,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";
@@ -241,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));
@@ -379,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;
}
@@ -490,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;
}
@@ -0,0 +1,100 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import java.util.Locale;
/**
* The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}.
*
* <p>
* The three schemes differ in where a posting of interest ends up, which is what the interest posting
* report has to show and the posting itself has to honour:
*
* <ul>
* <li>{@link #SINGLE} — short deposits, priced from a slab table in days. Interest stays in the
* deposit and is paid with the principal at maturity.</li>
* <li>{@link #DOUBLE} — deposits of a year or more, priced from a slab table in years. Interest is
* compounded back into the deposit, so the maturity value is principal plus interest.</li>
* <li>{@link #SPECIAL} — the monthly income scheme. Interest is posted monthly and swept out to the
* customer's linked savings account, which is why a special deposit cannot be opened without one;
* the deposit itself matures at its principal, the interest having already been paid away.</li>
* </ul>
*
* <p>
* The database column is a string rather than an enum ordinal, so the names here are the stored
* values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks
* because the column carries a default of SINGLE and older products predate the column entirely.
*/
public enum FixedDepositType {
/** Interest is held in the deposit and paid at maturity. The default for a product that names none. */
SINGLE,
/** Interest is compounded into the deposit; the maturity value includes it. */
DOUBLE,
/** Interest is posted monthly and transferred to the linked savings account. */
SPECIAL;
/**
* The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}.
*
* <p>
* A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a
* product carrying something else is a misconfiguration that must not stop its customers' interest
* from being reported.
*/
public static FixedDepositType fromString(final String fdType) {
if (fdType == null || fdType.isBlank()) {
return SINGLE;
}
for (final FixedDepositType candidate : values()) {
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
return candidate;
}
}
return SINGLE;
}
public static boolean isValid(final String fdType) {
if (fdType == null || fdType.isBlank()) {
return false;
}
for (final FixedDepositType candidate : values()) {
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
return true;
}
}
return false;
}
/** Whether interest leaves the deposit for the linked savings account each time it is posted. */
public boolean isPaidOutMonthly() {
return this == SPECIAL;
}
/** Whether a posting of interest adds to the deposit's own balance. */
public boolean isRetainedInDeposit() {
return this != SPECIAL;
}
/** What a customer sees: "FD Special", "FD Double", "FD Single". */
public String displayName() {
return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH);
}
}
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings;
import lombok.Getter;
/**
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
* server regardless of what a client happened to also send for the plain waive-penalty flag.
*/
@Getter
public enum PrematureClosureReasonType {
INVALID(0, "prematureClosureReasonType.invalid", false), //
NORMAL(100, "prematureClosureReasonType.normal", false), //
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
private final Integer value;
private final String code;
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
* waive-penalty flag also sent -- see the class javadoc. */
private final boolean autoWaived;
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
this.value = value;
this.code = code;
this.autoWaived = autoWaived;
}
public static PrematureClosureReasonType fromInt(final Integer v) {
if (v == null) {
return INVALID;
}
return switch (v) {
case 100 -> NORMAL;
case 200 -> PREMATURE_EXTENSION;
case 300 -> CONVERSION_TO_ANOTHER_TD;
case 400 -> DECEASED_SETTLEMENT;
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
default -> INVALID;
};
}
public boolean isInvalid() {
return this.equals(INVALID);
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
@@ -42,6 +43,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
@@ -82,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@Slf4j
@RequiredArgsConstructor
public class AccountingProcessorHelper {
@@ -661,37 +664,78 @@ public class AccountingProcessorHelper {
final GLAccount payableAccount = Optional
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
.orElse(payableAccount);
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
// named Payable account if the product has one, or straight against Savings Control if it
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
// entries for interest until this leg existed, since a product with no Payable/Paid names
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
// to post in their place.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
amount, isReversal);
// The interest expense is not raised again here. It was just raised above, and this posting
// only settles that recognised debt onward to where it is actually paid out from -- raising
// the expense a second time would put the same interest on the P&L twice.
//
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
if (!payableIsPaid) {
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// A product that names no payable falls back to Savings Control above. The chain below would
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
// reverse, the two cancelling: the expense never charged and the member never credited. Such
// a product has no liability to draw down, so it keeps the single pair it always posted.
if (payableAccount.getId().equals(controlAccount.getId())) {
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
payableAccount.getId());
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
return;
}
// Stage 3: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// The interest is charged to expense and raised as a liability first, for whatever part of
// it no accrual has already covered.
//
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
// job never covered - the payable went negative by the interest and the expense netted to
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
// account cannot hold a negative balance: the bank does not owe less than nothing.
//
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
// payable already carries the interest and this leg writes nothing - the balance below is
// enough, and raising it again would double the liability.
//
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
// debit balance would come back negative and every posting would raise a shortfall larger
// than the interest itself.
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
alreadyAccrued, shortfall, payableIsLiability, isReversal);
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
shortfall, false);
}
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// Stage 3: Out of the interest-paid account, into the member's control account
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
} else {
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
payableAccount.getName(), controlAccount.getName(), amount);
// Payout directly from payable into member's control account
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
}
/**
@@ -733,34 +777,69 @@ public class AccountingProcessorHelper {
*
* @return the account, or null where the product does not configure this name
*/
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
if (savingsProductId == null) {
/**
* What the interest payable account currently holds for this office, on its own liability side.
*
* <p>
* Read so an interest posting can tell an accrual that already ran from one that did not. The
* posting chain draws the payable down; where nothing had raised it, the account went negative
* and the expense netted to nothing, so the charge never reached the P&amp;L at all. The caller
* raises only the shortfall, which is nil when the accrual did run.
*
* <p>
* Credits less debits, because a payable is a liability: the balance is what the bank still
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
* report, and the caller's arithmetic needs the true figure, not a clamped one.
*/
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
if (payableAccount == null || payableAccount.getId() == null) {
return BigDecimal.ZERO;
}
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
// nothing, and counting either side alone would move the balance by the whole amount.
final BigDecimal balance = 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 office_id = ?
AND currency_code = ?
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)
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
return balance == null ? BigDecimal.ZERO : balance;
}
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
return null;
}
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document - so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding at the same query level as the cast would only work by
// luck.
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
LIMIT 1
""", Long.class, savingsProductId, accountName);
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
for (final String accountName : accountNames) {
if (accountName == null) {
continue;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
LIMIT 1
""", Long.class, savingsProductId, accountName);
if (!found.isEmpty()) {
return glAccountRepository.findById(found.get(0)).orElse(null);
}
}
return null;
}
/**
@@ -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);
}
}
@@ -84,4 +84,8 @@ public final class LedgerTxnPrefix {
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
// ── Fixed deposits ────────────────────────────────────────────────────────
/** Approved fixed deposit interest posting report, for one product, branch and date. */
public static final String FD_INTEREST_POSTED = "FDINT";
}
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component;
@Path("/v1/loans")
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
public class LoanCOBCatchUpApiResource {
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
private final PlatformSecurityContext context;
@GET
@Path("oldest-cob-closed")
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
public Response executeLoanCOBCatchUp() {
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
return Response.status(Response.Status.BAD_REQUEST).build();
@@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return property.isEnabled();
}
/**
* Read on every run of the interest posting job and of the day close, so the two paths can never
* both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid
* from an approved report, with it off the job pays them directly as it always did.
*/
@Override
public boolean isFixedDepositInterestPostingApprovalEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled();
}
@Override
public Integer retrieveFinancialYearBeginningMonth() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -165,9 +165,11 @@ public class SecurityConfig {
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password (issued via
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
// outer gate stays open for this one narrow GET path, and
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
// since any bank's own super user reaching it could mint a platform-wide key). Same
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
// the caller (ApiClientCredentialService checks the key's hash, active flag and
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
@@ -222,7 +224,10 @@ public class SecurityConfig {
// currency
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies"))
// the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) -
// this matcher previously targeted POST, which meant PUT fell through to the generic
// fullyAuthenticated() catch-all with no permission check at all.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
// notes: read
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
@@ -355,11 +360,18 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
// payment type
// NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a
// second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and
// no PUT/DELETE matcher existed at all, so those requests fell through to the generic
// fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher
// per method, each requiring its own correct permission code.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
// mix: taxonomy
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
@@ -390,12 +402,41 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
// working days
// NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between
// WORKING and DAYS), which did not match the actual seeded permission codes
// (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS")
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS")
// sponsor banks
// NOTE: no matcher previously existed at all for this path - POST/PUT both fell
// through to the generic fullyAuthenticated() catch-all below with no permission
// check whatsoever.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK")
// sponsor DD GST/GL settings
// NOTE: no matcher previously existed at all for this path either. gl-mapping is a
// genuine upsert (one call both creates and updates the single settings row), so it is
// covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING")
// bank profile (institution profile)
// NOTE: no matcher previously existed at all for this path - the PUT relied solely on
// the in-resource validateHasPermissionTo() check, which this change replaces with the
// command-source pipeline. Added here for the same defense-in-depth already applied to
// Currency/Payment Type/Working Days.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE")
// branch profile
// NOTE: no matcher previously existed at all for this path either, same rationale as
// institution profile above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
private final TenantDataSourceFactory tenantDataSourceFactory;
private final Environment environment;
private final TenantInitialUserProvisioner initialUserProvisioner;
// DO NOT REMOVE! Required for liquibase custom task initialization
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
// configuration
// settings, and we should not use them to control the script order
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
if (brandNewTenant) {
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
}
}
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
tenant.getTenantIdentifier());
@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service.migration;
import java.util.Locale;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.useradministration.service.AppUserConstants;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
* public default password).
*
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
* break validation on every tenant that already ran it.
*
* Usernames become {@code <tenantIdentifier>_admin} and {@code <tenantIdentifier>_interop}. The system user keeps the
* {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it
* up by that name; only its password is changed. All three share one password: the value of
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
* otherwise {@link #DEFAULT_PASSWORD}.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantInitialUserProvisioner {
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
private static final long ADMIN_ID = 1L;
private static final long SYSTEM_ID = 2L;
private static final long INTEROP_ID = 3L;
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
private final Environment environment;
public void provision(final DataSource dataSource, final String tenantIdentifier) {
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
final String prefix = usernamePrefix(tenantIdentifier);
final String configured = environment.getProperty(PASSWORD_PROPERTY);
final boolean overridden = configured != null && !configured.isBlank();
final String password = overridden ? configured : DEFAULT_PASSWORD;
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final String encoded = encoder.encode(password);
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded);
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.",
tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
}
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
encodedPassword, id);
}
static String usernamePrefix(final String tenantIdentifier) {
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
return cleaned.isEmpty() ? "tenant" : cleaned;
}
}
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
* key in the first place is not something to hand to just anyone.
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
*
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
* whoever already has direct database access -- never over HTTP.
*/
@Path("/v1/apiclients")
@Component
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
private final PlatformSecurityContext context;
private final ApiClientCredentialService credentialService;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String create(final String body) {
final AppUser user = requireSuperUser();
final JsonElement root = fromJsonHelper.parse(body);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
return gson.toJson(result);
requireSuperUser();
throw new WebApplicationException(Response.status(403)
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
.type(MediaType.APPLICATION_JSON).build());
}
@GET
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
@Override
public Collection<TellerData> getTellers(Long officeId) {
return retrieveAllTellers(false);
if (officeId == null) {
return retrieveAllTellers(false);
}
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
}
@Override
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
try {
this.context.authenticatedUser();
if (this.tellerRepositoryWrapper.existsAny()) {
throw new TellerAlreadyExistsException();
}
final Long officeId = command.longValueOfParameterNamed("officeId");
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
// final Office parent =
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
// its own cash, so a second branch needs a vault of its own.
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
throw new TellerAlreadyExistsException(tellerOffice.getName());
}
final Teller teller = Teller.fromJson(tellerOffice, command);
// pre save to generate id for use in office hierarchy
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
import org.springframework.stereotype.Component;
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
private final CommandPipeline commandPipeline;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) {
public String update(@Valid final WorkingDaysUpdateRequest request) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays()
.withJson(toApiJsonSerializer.serialize(request)).build();
final var command = new WorkingDaysUpdateCommand();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
command.setCommandId(System.currentTimeMillis());
command.setCreatedAt(Instant.now());
command.setPayload(request);
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
return response.get();
return toApiJsonSerializer.serialize(result);
}
@GET
@@ -18,38 +18,46 @@
*/
package org.apache.fineract.organisation.workingdays.handler;
import java.util.Collections;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
@Service
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
@Autowired
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
}
@Transactional
@Override
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
WorkingDaysUpdateRequest request = command.getPayload();
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
if (changes == null) {
changes = Collections.emptyMap();
}
public CommandProcessingResult processCommand(final JsonCommand command) {
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
.recurrence(command.stringValueOfParameterNamed("recurrence"))
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
.extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments"))
.extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays"))
.build();
final Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.with(changes) //
.build();
}
}
@@ -46,6 +46,10 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -69,10 +73,28 @@ import org.springframework.transaction.annotation.Transactional;
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
/**
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
* own office. A person moved to another branch's teller keeps what they took at the old branch
* there.
*/
private static final String COUNTER_OFFICE_OF_STD = """
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
LIMIT 1),
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
""";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VaultPositionService vaultPositionService;
private final SavingsInterestPostingReportService interestPostingReportService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -191,8 +213,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
@@ -201,27 +223,29 @@ public class BranchDayRegisterApiResource {
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.COUNTED_ROW + """
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 (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.COUNTED_ROW + """
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 (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
// What will stop this close, so the screen can say so before the button is pressed rather than
// after. The same lists closeDay() refuses on, read-only here.
result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate));
// One call for the deposits: what is blocking, and the products and amounts behind it, so the
// branch sees what it is being asked to approve without leaving the close screen. Pricing a
// branch's deposits is the expensive part and is done once.
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
result.put("fdInterestPending", fdInterest.get("pending"));
result.put("fdInterestDue", fdInterest.get("due"));
return gson.toJson(result);
}
@@ -589,6 +613,43 @@ public class BranchDayRegisterApiResource {
"Business day " + businessDate + " is already closed.");
}
// ── This branch's savings interest reports must be approved before it closes ──
// Not overridable by a force-close reason: the branch posts its own customers' interest when
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!interestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
"Cannot close the day — this branch's savings interest posting report is not approved for: "
+ String.join(", ", interestPending)
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
}
// ── And its fixed deposit interest reports, on the same grounds ──
// Only while deposits are on the approval path at all; with that switched off the posting job
// pays them directly and there is nothing here to approve.
final List<String> fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!fdInterestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending",
"Cannot close the day — this branch's fixed deposit interest posting report is not approved for: "
+ String.join(", ", fdInterestPending)
+ ". Approve it under Reports → FD Interest Posting Reports, then close the day.");
}
// ── The head office closes last ──
// Every branch that opened the day closes first and posts its own interest; a branch that
// never opened is not waited for.
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
if (isHeadOffice) {
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
// still working and has not posted its interest.
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
if (!stillOpen.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
+ ". The head office closes last.");
}
}
// ── Ensure every cashier holding cash has been reconciled AND settled ──
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
// and if so, verify reconciliation and settlement records exist for the business date.
@@ -687,6 +748,45 @@ public class BranchDayRegisterApiResource {
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
// ── The branch posts its own approved savings interest as it closes ──
// The head office also posts what it approved for branches that did not open today. Entries
// are raised in the customers' own branch either way.
//
// Posted here, before the close is written, and synchronously, so the outcome is never
// silent: each report posts in a transaction of its own and commits, and if any fails the
// close is refused with the reason — the day stays open, the report stays APPROVED, and
// closing again once the cause is fixed posts it.
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!toPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
if (!interestFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// ── And the branch's approved fixed deposit interest, the same way ──
// Separate from the savings run so a deposit product with a GL problem cannot stop savings
// interest that has already posted cleanly, and so the refusal names which of the two failed.
final List<Long> fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!fdToPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size());
final List<String> fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost);
if (!fdFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed",
"Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
@@ -1004,6 +1104,9 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
// The close screen fetches this office's tellers with it; left out, it fetched every teller
// the user can see, and the head office counted each branch's vault as its own.
m.put("officeId", r.get("office_id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
@@ -1057,11 +1160,13 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
// By the counter that took the cash. A savings deposit carries no office_id, and the old
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
AND COALESCE(counter_office_id, office_id) = ?
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
);
// Globally unique voucher number from the central m_voucher register
@@ -691,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,
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
+ "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
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
+ "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)
@@ -671,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",
@@ -721,7 +721,7 @@ public class CashReportApiResource {
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
@@ -1171,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
@@ -1197,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)
@@ -1315,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 ("
@@ -709,6 +709,21 @@ public class ClerkShareWithdrawalRequestApiResource {
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
// The Share Sundry account the redemption draws on must actually hold the money.
//
// collectionApplied above says A collection was applied to THIS account, not that the
// proceeds are still sitting in Sundry - the same holding account serves every share
// account, and an earlier redemption may already have drawn it down. Approving past that
// takes Sundry negative, which is a liability owing less than nothing, and the overdraw
// lands inside a balance-sheet head that totals positive, where it does not look wrong.
final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
if (redemptionValue != null) {
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
shareAccountId);
GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
"The Share Sundry (Suspense) account");
}
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -958,6 +973,33 @@ public class ClerkShareWithdrawalRequestApiResource {
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
* reset id sequence in a dev environment can violate.
*/
/**
* What a redemption of this many shares will draw out of Share Sundry.
*
* <p>
* Priced at the unit price the member's own shares were issued at, which is the price the
* redemption itself uses. The product's nominal price is not consulted: a member who subscribed
* at an earlier price redeems at theirs, and pricing the guard differently from the posting
* would either block a redemption that fits or let one through that does not.
*
* @return the value, or null where no price can be established - the guard then stands aside
* rather than blocking on a figure it had to invent.
*/
private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
return null;
}
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
final BigDecimal unitPrice = jdbcTemplate
.queryForList("""
SELECT unit_price FROM m_share_account_transaction
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
ORDER BY id DESC LIMIT 1
""", BigDecimal.class, shareAccountId)
.stream().findFirst().orElse(null);
return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
}
private Long redeemDebitGlOf(final Long shareTransactionId) {
if (shareTransactionId == null) {
return null;
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
+ " AND" + REAL_TXN_ID + ") ";
/**
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
* officer's login, so without this a person moved from one branch's teller to another's carried
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
* with no recorded creation time keeps its whole history.
*/
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
+ " OR std.created_at >= mc.created_at)";
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
private static String breakdownDiffersFrom(final String amountColumn) {
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
}
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " 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"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " 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"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " 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"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " 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"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
@@ -114,4 +115,16 @@ public class FundsApiResource {
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
@DELETE
@Path("{fundId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Delete a Fund", description = "Deletes a fund outright. Fails if the fund is still referenced by any loan or loan product.")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FundsApiResourceSwagger.DeleteFundsFundIdResponse.class)))
public String deleteFund(@PathParam("fundId") @Parameter(description = "fundId") final Long fundId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteFund(fundId).build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return toApiJsonSerializer.serialize(result);
}
}
@@ -55,4 +55,13 @@ final class FundsApiResourceSwagger {
public Long resourceId;
public PutFundsFundIdRequest changes;
}
@Schema(description = "DeleteFundsFundIdResponse")
public static final class DeleteFundsFundIdResponse {
private DeleteFundsFundIdResponse() {}
@Schema(example = "1")
public Long resourceId;
}
}
@@ -0,0 +1,47 @@
/**
* 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.fund.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.fund.service.FundWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FUND", action = "DELETE")
public class DeleteFundCommandHandler implements NewCommandSourceHandler {
private final FundWritePlatformService writePlatformService;
@Autowired
public DeleteFundCommandHandler(final FundWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteFund(command.entityId(), command);
}
}
@@ -26,4 +26,6 @@ public interface FundWritePlatformService {
CommandProcessingResult createFund(JsonCommand command);
CommandProcessingResult updateFund(Long fundId, JsonCommand command);
CommandProcessingResult deleteFund(Long fundId, JsonCommand command);
}
@@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.fund.exception.FundNotFoundException;
import org.apache.fineract.portfolio.fund.serialization.FundCommandFromApiJsonDeserializer;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -45,6 +46,7 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf
private final PlatformSecurityContext context;
private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final FundRepository fundRepository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -99,6 +101,45 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf
}
}
@Transactional
@Override
@CacheEvict(value = "funds", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat('fn')")
public CommandProcessingResult deleteFund(final Long fundId, final JsonCommand command) {
this.context.authenticatedUser();
final Fund fund = this.fundRepository.findById(fundId).orElseThrow(() -> new FundNotFoundException(fundId));
requireNoneReference(fundId, "m_loan", "fund_id", "loan", "loans",
"Reassign or remove the loans using this fund before deleting it.");
requireNoneReference(fundId, "m_product_loan", "fund_id", "loan product", "loan products",
"Remove this fund from the loan products using it before deleting it.");
try {
this.fundRepository.delete(fund);
this.fundRepository.flush();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references",
"This fund cannot be deleted: other records still reference it.");
} catch (final PersistenceException dve) {
throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references",
"This fund cannot be deleted: other records still reference it.");
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(fundId).build();
}
private void requireNoneReference(final Long fundId, final String table, final String column, final String singular,
final String plural, final String remedy) {
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
fundId);
if (count != null && count > 0) {
final String noun = count == 1 ? singular : plural;
throw new PlatformDataIntegrityException("error.msg.fund.cannot.delete.still.referenced",
"This fund cannot be deleted: it is still referenced by " + count + " " + noun + ". " + remedy, "fundId", fundId);
}
}
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
*/
@@ -42,7 +42,7 @@ public class FundConfiguration {
@Bean
@ConditionalOnMissingBean(FundWritePlatformService.class)
public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context,
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository) {
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository);
FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository, JdbcTemplate jdbcTemplate) {
return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository, jdbcTemplate);
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.orgsetup.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
@@ -27,17 +28,18 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.Instant;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -57,6 +59,7 @@ public class BranchProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
@GET
@@ -84,68 +87,23 @@ public class BranchProfileApiResource {
@PUT
@Path("{officeId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String update(@PathParam("officeId") final Long officeId, final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
Integer.class, officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
public CommandProcessingResult update(@PathParam("officeId") final Long officeId, final String body) {
// The write itself (office-exists check, branch-code uniqueness check, m_office_profile
// upsert) now lives in BranchProfileWritePlatformService, invoked via
// UpdateBranchProfileCommandHandler so it goes through the generic Maker-Checker gate. The
// permission check that used to happen here explicitly is now enforced by SecurityConfig's
// matcher plus the command-source pipeline itself, the same as every other endpoint already
// migrated to this pipeline. A "locale" key is added if the body does not already carry one -
// JsonCommand's BigDecimal extractor (for latitude/longitude) requires it, and the frontend's
// payload does not currently send one.
final var root = this.fromJsonHelper.parse(body);
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
final String address = this.fromJsonHelper.extractStringNamed("address", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root);
final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root);
final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root);
final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root);
final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root);
final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root);
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
final JsonObject json = root.getAsJsonObject();
if (!json.has("locale")) {
json.addProperty("locale", "en");
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return list(officeId);
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateBranchProfile(officeId)
.withJson(this.gson.toJson(json)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private String baseSql() {
@@ -25,14 +25,16 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -60,6 +62,7 @@ public class InstitutionProfileApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
@GET
@@ -89,100 +92,17 @@ public class InstitutionProfileApiResource {
@PUT
@Consumes({ MediaType.APPLICATION_JSON })
public String update(final String body) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
ensureRowExists();
final var root = this.fromJsonHelper.parse(body);
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root);
final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root);
final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root);
final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root);
final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root));
final String status = this.fromJsonHelper.extractStringNamed("status", root);
final String country = this.fromJsonHelper.extractStringNamed("country", root);
final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root);
final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root));
final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root);
final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root));
final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root);
final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root);
final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root);
final String city = this.fromJsonHelper.extractStringNamed("city", root);
final String district = this.fromJsonHelper.extractStringNamed("district", root);
final String state = this.fromJsonHelper.extractStringNamed("state", root);
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE;
final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root);
final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root)
? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE;
final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root);
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root);
final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root);
final String website = this.fromJsonHelper.extractStringNamed("website", root);
final String pan = this.fromJsonHelper.extractStringNamed("pan", root);
final String tan = this.fromJsonHelper.extractStringNamed("tan", root);
final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root);
final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root);
final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root);
final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root);
final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root);
final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root);
final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation",
root);
final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root);
final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root);
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country,
regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic,
addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress,
communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail,
website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions,
authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail,
turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(),
Timestamp.from(Instant.now()));
return get();
public CommandProcessingResult update(final String body) {
// The write itself (the JDBC UPDATE of m_institution_profile) now lives in
// InstitutionProfileWritePlatformService, invoked via UpdateInstitutionProfileCommandHandler so
// it goes through the generic Maker-Checker gate. The permission check that used to happen here
// explicitly is now enforced by SecurityConfig's matcher plus the command-source pipeline
// itself, the same as every other endpoint already migrated to this pipeline (Currency, Working
// Days, Payment Type). `body` is forwarded to the command as-is - it already carries the
// `locale` key the frontend sends, which JsonCommand's BigDecimal/date extractors would
// otherwise require.
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateInstitutionProfile().withJson(body).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private void ensureRowExists() {
@@ -204,14 +124,6 @@ public class InstitutionProfileApiResource {
return codes.isEmpty() ? null : codes.get(0);
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
private Map<String, Object> toResult(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("bankName", row.get("bank_name"));
@@ -0,0 +1,62 @@
/**
* 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.orgsetup.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.orgsetup.service.BranchProfileWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "BRANCHPROFILE", action = "UPDATE")
public class UpdateBranchProfileCommandHandler implements NewCommandSourceHandler {
private final BranchProfileWritePlatformService branchProfileWritePlatformService;
@Autowired
public UpdateBranchProfileCommandHandler(final BranchProfileWritePlatformService branchProfileWritePlatformService) {
this.branchProfileWritePlatformService = branchProfileWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final Long officeId = command.entityId();
final CommandProcessingResult result = this.branchProfileWritePlatformService.updateBranchProfile(officeId,
command.stringValueOfParameterNamed("branchCode"), command.stringValueOfParameterNamed("branchType"),
command.stringValueOfParameterNamed("address"), command.stringValueOfParameterNamed("pincode"),
command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"),
command.stringValueOfParameterNamed("workingHoursStart"),
command.stringValueOfParameterNamed("workingHoursEnd"), command.stringValueOfParameterNamed("workingDays"),
command.stringValueOfParameterNamed("ifscCode"), command.stringValueOfParameterNamed("micrCode"),
command.bigDecimalValueOfParameterNamed("latitude"), command.bigDecimalValueOfParameterNamed("longitude"));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -0,0 +1,88 @@
/**
* 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.orgsetup.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.orgsetup.service.InstitutionProfileWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INSTITUTIONPROFILE", action = "UPDATE")
public class UpdateInstitutionProfileCommandHandler implements NewCommandSourceHandler {
private final InstitutionProfileWritePlatformService institutionProfileWritePlatformService;
@Autowired
public UpdateInstitutionProfileCommandHandler(
final InstitutionProfileWritePlatformService institutionProfileWritePlatformService) {
this.institutionProfileWritePlatformService = institutionProfileWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final Boolean headOfficeSame = command.parameterExists("headOfficeSameAsRegistered")
? command.booleanObjectValueOfParameterNamed("headOfficeSameAsRegistered") : Boolean.TRUE;
final Boolean communicationSame = command.parameterExists("communicationSameAsRegistered")
? command.booleanObjectValueOfParameterNamed("communicationSameAsRegistered") : Boolean.TRUE;
final CommandProcessingResult result = this.institutionProfileWritePlatformService.updateInstitutionProfile(
command.stringValueOfParameterNamed("bankName"), command.stringValueOfParameterNamed("legalName"),
command.stringValueOfParameterNamed("shortName"), command.stringValueOfParameterNamed("bankCode"),
command.stringValueOfParameterNamed("institutionCode"), command.stringValueOfParameterNamed("bankType"),
command.stringValueOfParameterNamed("ownershipType"),
command.stringValueOfParameterNamed("parentInstitutionName"),
command.stringValueOfParameterNamed("registrationNumber"),
command.stringValueOfParameterNamed("licenseNumber"), command.stringValueOfParameterNamed("establishedDate"),
command.stringValueOfParameterNamed("status"), command.stringValueOfParameterNamed("country"),
command.stringValueOfParameterNamed("regulatoryAuthority"),
command.stringValueOfParameterNamed("registrationDate"),
command.stringValueOfParameterNamed("licenseStatus"),
command.stringValueOfParameterNamed("licenseExpiryDate"), command.stringValueOfParameterNamed("swiftBic"),
command.stringValueOfParameterNamed("addressLine1"), command.stringValueOfParameterNamed("addressLine2"),
command.stringValueOfParameterNamed("city"), command.stringValueOfParameterNamed("district"),
command.stringValueOfParameterNamed("state"), command.stringValueOfParameterNamed("pincode"), headOfficeSame,
command.stringValueOfParameterNamed("headOfficeAddress"), communicationSame,
command.stringValueOfParameterNamed("communicationAddress"),
command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"),
command.stringValueOfParameterNamed("customerCareNumber"),
command.stringValueOfParameterNamed("grievanceEmail"), command.stringValueOfParameterNamed("website"),
command.stringValueOfParameterNamed("pan"), command.stringValueOfParameterNamed("tan"),
command.stringValueOfParameterNamed("gstin"), command.stringValueOfParameterNamed("statementHeaderName"),
command.stringValueOfParameterNamed("passbookHeaderName"),
command.stringValueOfParameterNamed("receiptHeaderName"),
command.stringValueOfParameterNamed("termsAndConditions"),
command.stringValueOfParameterNamed("authorizedPersonName"),
command.stringValueOfParameterNamed("authorizedPersonDesignation"),
command.stringValueOfParameterNamed("authorizedPersonContact"),
command.stringValueOfParameterNamed("authorizedPersonEmail"));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -0,0 +1,29 @@
/**
* 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.orgsetup.service;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface BranchProfileWritePlatformService {
CommandProcessingResult updateBranchProfile(Long officeId, String branchCode, String branchType, String address,
String pincode, String contactPhone, String contactEmail, String workingHoursStart, String workingHoursEnd,
String workingDays, String ifscCode, String micrCode, BigDecimal latitude, BigDecimal longitude);
}
@@ -0,0 +1,103 @@
/**
* 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.orgsetup.service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Moved verbatim out of {@code BranchProfileApiResource.update()} so the write can be invoked from a
* {@code NewCommandSourceHandler} for Maker-Checker support. The resource's own {@code list()} (read)
* method is untouched.
*/
@Service
public class BranchProfileWritePlatformServiceImpl implements BranchProfileWritePlatformService {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Autowired
public BranchProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) {
this.jdbcTemplate = jdbcTemplate;
this.context = context;
}
@Transactional
@Override
public CommandProcessingResult updateBranchProfile(final Long officeId, final String branchCode, final String branchType,
final String address, final String pincode, final String contactPhone, final String contactEmail,
final String workingHoursStart, final String workingHoursEnd, final String workingDays, final String ifscCode,
final String micrCode, final BigDecimal latitude, final BigDecimal longitude) {
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", Integer.class,
officeId);
if (officeExists == null || officeExists == 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
"Office " + officeId + " was not found.");
}
if (branchCode != null && !branchCode.isBlank()) {
final Integer dupe = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
branchCode, officeId);
if (dupe != null && dupe > 0) {
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
"Branch code '" + branchCode + "' is already used by another branch.");
}
}
final AppUser currentUser = this.context.authenticatedUser();
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
jdbcTemplate.update("""
UPDATE m_office_profile
SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
working_hours_start = ?, working_hours_end = ?, working_days = ?,
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
updated_by_username = ?, updated_at = ?
WHERE office_id = ?
""", branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()), officeId);
} else {
jdbcTemplate.update("""
INSERT INTO m_office_profile
(office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
latitude, longitude, updated_by_username, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
Timestamp.from(Instant.now()));
}
return new CommandProcessingResultBuilder().withEntityId(officeId).build();
}
}
@@ -0,0 +1,36 @@
/**
* 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.orgsetup.service;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InstitutionProfileWritePlatformService {
CommandProcessingResult updateInstitutionProfile(String bankName, String legalName, String shortName, String bankCode,
String institutionCode, String bankType, String ownershipType, String parentInstitutionName,
String registrationNumber, String licenseNumber, String establishedDate, String status, String country,
String regulatoryAuthority, String registrationDate, String licenseStatus, String licenseExpiryDate,
String swiftBic, String addressLine1, String addressLine2, String city, String district, String state,
String pincode, Boolean headOfficeSameAsRegistered, String headOfficeAddress,
Boolean communicationSameAsRegistered, String communicationAddress, String contactPhone, String contactEmail,
String customerCareNumber, String grievanceEmail, String website, String pan, String tan, String gstin,
String statementHeaderName, String passbookHeaderName, String receiptHeaderName, String termsAndConditions,
String authorizedPersonName, String authorizedPersonDesignation, String authorizedPersonContact,
String authorizedPersonEmail);
}
@@ -0,0 +1,134 @@
/**
* 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.orgsetup.service;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Moved verbatim out of {@code InstitutionProfileApiResource.update()} so the write can be invoked
* from a {@code NewCommandSourceHandler} for Maker-Checker support. {@code ensureRowExists()},
* {@code parseDate()} and {@code toSqlDate()} are copies of the resource's own private helpers rather
* than shared ones - the resource's untouched {@code get()} method keeps its own copies so this change
* does not have to touch that unrelated read path at all.
*/
@Service
public class InstitutionProfileWritePlatformServiceImpl implements InstitutionProfileWritePlatformService {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Autowired
public InstitutionProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) {
this.jdbcTemplate = jdbcTemplate;
this.context = context;
}
@Transactional
@Override
public CommandProcessingResult updateInstitutionProfile(final String bankName, final String legalName,
final String shortName, final String bankCode, final String institutionCode, final String bankType,
final String ownershipType, final String parentInstitutionName, final String registrationNumber,
final String licenseNumber, final String establishedDate, final String status, final String country,
final String regulatoryAuthority, final String registrationDate, final String licenseStatus,
final String licenseExpiryDate, final String swiftBic, final String addressLine1, final String addressLine2,
final String city, final String district, final String state, final String pincode,
final Boolean headOfficeSameAsRegistered, final String headOfficeAddress,
final Boolean communicationSameAsRegistered, final String communicationAddress, final String contactPhone,
final String contactEmail, final String customerCareNumber, final String grievanceEmail, final String website,
final String pan, final String tan, final String gstin, final String statementHeaderName,
final String passbookHeaderName, final String receiptHeaderName, final String termsAndConditions,
final String authorizedPersonName, final String authorizedPersonDesignation, final String authorizedPersonContact,
final String authorizedPersonEmail) {
ensureRowExists();
// A status flip is stamped automatically the first time it happens, rather than asking the
// user to also remember to set a date — the previous row's own status is what "the first
// time" is judged against.
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
String.class);
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
final LocalDate today = LocalDate.now();
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
UPDATE m_institution_profile
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
swift_bic = ?,
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
head_office_same_as_registered = ?, head_office_address = ?,
communication_same_as_registered = ?, communication_address = ?,
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
website = ?, pan = ?, tan = ?, gstin = ?,
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
terms_and_conditions = ?,
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
authorized_person_email = ?,
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
updated_by_username = ?, updated_at = ?
WHERE id = 1
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(parseDate(establishedDate)), status, country,
regulatoryAuthority, toSqlDate(parseDate(registrationDate)), licenseStatus, toSqlDate(parseDate(licenseExpiryDate)),
swiftBic, addressLine1, addressLine2, city, district, state, pincode, headOfficeSameAsRegistered,
headOfficeAddress, communicationSameAsRegistered, communicationAddress, contactPhone, contactEmail,
customerCareNumber, grievanceEmail, website, pan, tan, gstin, statementHeaderName, passbookHeaderName,
receiptHeaderName, termsAndConditions, authorizedPersonName, authorizedPersonDesignation,
authorizedPersonContact, authorizedPersonEmail, turningActive, toSqlDate(today), turningInactive,
toSqlDate(today), currentUser.getUsername(), Timestamp.from(Instant.now()));
return new CommandProcessingResultBuilder().withEntityId(1L).build();
}
private void ensureRowExists() {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1",
Integer.class);
if (count == null || count == 0) {
final AppUser currentUser = this.context.authenticatedUser();
jdbcTemplate.update("""
INSERT INTO m_institution_profile (id, status, created_by_username, created_at)
VALUES (1, 'ACTIVE', ?, ?)
""", currentUser.getUsername(), Timestamp.from(Instant.now()));
}
}
private LocalDate parseDate(final String raw) {
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
}
private Date toSqlDate(final LocalDate d) {
return d == null ? null : Date.valueOf(d);
}
}
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
"The generated return must be supplied with the save.");
}
final LocalDate periodStart = localDate(report, "fortnightStart");
final LocalDate periodEnd = localDate(report, "fortnightEnd");
final BigDecimal ndtl = decimal(report, "ndtlAmount");
final BigDecimal required = decimal(report, "requiredAmount");
final BigDecimal maintained = decimal(report, "maintainedAmount");
final BigDecimal rate = decimal(report, "ratePercentage");
// The statutory form states its figures per column inside Part B rather than once at the
// top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
// read out of it. Without this a filed statutory return listed as a row of dashes, and the
// list could not be scanned for the one return somebody was looking for.
final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
&& "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
// The statutory form's period runs between its two fortnight closes, which is what the
// saved list prints as the row's period.
final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
// The current fortnight's figures are the second column - the close the form is made up to.
final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
: decimal(report, "maintainedAmount");
final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
final String value = object.get(field).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
/**
* One of a statutory part's per-column figures, taken at the column the form is made up to.
*
* The form carries three columns and the saved list has one cell per figure, so it takes the
* second - the close the return reports. Index 1 rather than "the last": the third column is
* the base the ratios are charged on, not a later position, and listing it would show a
* requirement against liabilities from a fortnight nobody filed.
*/
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
if (!report.has(part) || !report.get(part).isJsonObject()) {
return null;
}
final JsonObject partObject = report.getAsJsonObject(part);
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
return null;
}
final var values = partObject.getAsJsonArray(field);
if (values.size() < 2 || values.get(1).isJsonNull()) {
return null;
}
return values.get(1).getAsBigDecimal();
}
/** One of the statutory form's column dates, for the period a saved row reports. */
private static LocalDate columnDate(final JsonObject report, final int index) {
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
return null;
}
final var columns = report.getAsJsonArray("columns");
if (columns.size() <= index || columns.get(index).isJsonNull()) {
return null;
}
final String value = columns.get(index).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
}
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
if (role.startsWith("OSS1_")) {
@@ -227,9 +228,15 @@ public class RbiReserveConfigApiResource {
*/
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
/** The four treatments a head can be given, each naming a branch the returns already have. */
/**
* The treatments a head can be given, each naming a branch the returns already have.
*
* ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
* at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
* subtracted from the liabilities of a return that has no such base.
*/
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
"MEMO");
"ASSET", "MEMO");
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
static {
@@ -243,6 +250,27 @@ public class RbiReserveConfigApiResource {
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
}
/**
* How a head is consumed, in the terms of the return it belongs to.
*
* LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
* head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
* base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
* wording would tell a Form IX filer their head feeds a calculation that does not exist on
* their return.
*/
private static String usedBy(final String group, final String usage) {
if ("FORM9".equals(group)) {
return switch (usage == null ? "" : usage) {
case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
case "ASSET" -> "Form IX — an asset; enters Total Assets";
case "MEMO" -> "Form IX — stated on the form but entering no total";
default -> usage;
};
}
return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
}
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
static {
@@ -390,11 +418,14 @@ public class RbiReserveConfigApiResource {
o.put("label", row.get("label"));
o.put("group", group);
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
o.put("usedBy", usedBy(group, usage));
o.put("builtIn", false);
o.put("usage", usage);
// A liability head carries the demand/time split; nothing else does.
o.put("classified", "LIABILITY".equals(usage));
// A liability head carries the demand/time split; nothing else does. Not on Form IX,
// where the demand and time heads are separate lines of the form itself - asking for a
// class on top of that would let a head declared under 5(a) Fixed Deposits be marked
// DEMAND, which the form has no way to report.
o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
o.put("roleId", row.get("id"));
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
out.add(o);
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
@Slf4j
public class RbiFormAAutoSaveService {
private static final String FORM_CODE = "FORM_1";
// The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
// Saving the working report's shape left rows in the list that opened as a blank statement:
// the viewer built for the statutory form found none of the fields it reads.
private static final String FORM_CODE = "FORM_1_STATUTORY";
private final JdbcTemplate jdbcTemplate;
private final RbiReserveReturnApiResource returnApiResource;
@@ -89,7 +92,11 @@ public class RbiFormAAutoSaveService {
try {
// Computed through the same endpoint the screen uses, so an auto-saved return and one
// saved by hand cannot come out differently.
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
// No column dates passed: today IS a fortnight close - the check above returned
// otherwise - so the form derives its own pair from it, which is the pair an unattended
// run should file. A filer needing a different period enters both dates on the screen.
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null),
JsonObject.class);
} catch (final RuntimeException e) {
// A return that cannot be computed is not an error worth failing the batch over - an
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
@@ -98,18 +105,22 @@ public class RbiFormAAutoSaveService {
return;
}
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
// The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
// on it - a bank with no balances at the State Co-operative Bank files that line empty - so
// the form is filed as it stands. What it cannot do without is a rate, because every
// required amount below is a percentage of one, and saving a return whose requirement is
// zero for want of configuration would put a compliant-looking statement on the record.
if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
today);
return;
}
final LocalDate periodStart = date(report, "fortnightStart");
final LocalDate periodEnd = date(report, "fortnightEnd");
BigDecimal average = null;
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
average = decimal(report.getAsJsonObject("maintenance"), "average");
}
// The form's period runs between its two fortnight closes, and its headline figures sit per
// column inside the parts rather than once at the top.
final LocalDate periodStart = column(report, 0);
final LocalDate periodEnd = column(report, 1);
final BigDecimal average = null;
jdbcTemplate.update("""
INSERT INTO m_rbi_form_a_filing
@@ -119,9 +130,9 @@ public class RbiFormAAutoSaveService {
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
""", FORM_CODE, java.sql.Date.valueOf(today),
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
"Saved automatically at the close of the fortnight.");
gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"),
partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight.");
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
}
@@ -143,6 +154,41 @@ public class RbiFormAAutoSaveService {
return object.get(field).getAsBigDecimal();
}
/**
* One of the statutory form's per-column figures, at the close the return is made up to.
*
* Index 1, the second column: the third is the base the ratios are charged on, not a later
* position, and listing it would show a requirement against a fortnight nobody filed.
*/
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
if (!report.has(part) || !report.get(part).isJsonObject()) {
return null;
}
final JsonObject partObject = report.getAsJsonObject(part);
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
return null;
}
final var values = partObject.getAsJsonArray(field);
if (values.size() < 2 || values.get(1).isJsonNull()) {
return null;
}
return values.get(1).getAsBigDecimal();
}
/** One of the form's column dates. */
private static LocalDate column(final JsonObject report, final int index) {
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
return null;
}
final var columns = report.getAsJsonArray("columns");
if (columns.size() <= index || columns.get(index).isJsonNull()) {
return null;
}
final String value = columns.get(index).getAsString();
return value == null || value.isBlank() ? null : LocalDate.parse(value);
}
@SuppressWarnings("unused")
private static LocalDate date(final JsonObject object, final String field) {
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
return null;
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
* Runs the day's savings interest: accrue for every account, and post interest only from approved
* interest posting reports ({@link SavingsInterestPostingReportService}).
*
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
private final SavingsInterestPostingReportService reportService;
private final PlatformTransactionManager transactionManager;
/**
* Accrue for the day, then post for any product whose posting date has arrived.
* The nightly run: settles any approved interest report still unposted, then accrues the day.
*
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
* posting then reads and updates those same rows in a transaction of its own
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
* another's payment). Holding an outer transaction across both would mean this thread still owns
* the row locks the accrual took while the posting's separate transaction waits for them — each
* waiting on the other, and the job hanging silently with nothing in the log. So each step
* commits on its own before the next begins.
* <p>
* This run no longer decides to pay anybody. Interest is paid only from an approved posting
* report, normally when the last branch closes the day. What is left here is the safety net for a
* report that was approved but could not be posted then, and it runs BEFORE the accrual on
* purpose: by the time this job runs the business date has already moved past the posting date,
* and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
* for the posting to settle.
*
* <p>
* Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
* accrual then commits on its own, so neither holds the other's row locks.
*/
public void runForDate(final LocalDate businessDate) {
clearStaleRunningFlag();
final List<String> failures = postApprovedReports(businessDate);
accrualService.accrueForDate(businessDate);
enrolNewProducts();
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
SELECT s.savings_product_id, s.posting_period_enum
FROM m_savings_interest_posting_schedule s
JOIN m_savings_product p ON p.id = s.savings_product_id
WHERE s.is_active = true AND p.deposit_type_enum = 100
ORDER BY s.savings_product_id
""");
// Products that could not be paid, collected so the run can fail loudly at the end.
//
// Carrying on past one product is right — a mapping missing on one product must not stop
// every member of every other from being paid. Reporting the job as successful afterwards is
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
// nothing reads. On a night when every product fails, that is a whole day of interest
// silently unpaid.
final List<String> failures = new java.util.ArrayList<>();
for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
// Whether anything is due is decided inside postForProduct, not here.
//
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
// deadlocked the run: accrueForDate above is @Transactional and has just written every
// ledger row, so its transaction still holds them; the COUNT then read those rows on
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
// transaction of its own — sat waiting for a lock this thread was holding and would only
// release after postForProduct returned. Each waited on the other and the job hung
// silently, with nothing in the log and the flag left saying it was still running.
//
// postForProduct already selects what is due and returns zero when there is nothing, so
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
// than by the run date, which is what makes a missed posting date catch up on a later
// run instead of being skipped for good.
try {
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
if (paid.signum() == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
updateSchedule(productId, businessDate, "COMPLETED");
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
} catch (final RuntimeException e) {
// The product rolled back on its own; record why and let the others run.
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
// the row — which is exactly what a product with no GL mapping does. The failure is
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE savings_product_id = ? AND posting_date = ?
""", truncate(e.getMessage()), productId, businessDate);
updateSchedule(productId, businessDate, "FAILED");
failures.add("product " + productId + ": " + e.getMessage());
}
// Keeps each product's next posting date on the timetable in step with the ledger, which
// the posting loop used to do as a side effect of looking for work.
for (final Long productId : jdbcTemplate.queryForList(
"SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
updateSchedule(productId, businessDate, null);
}
// Fails the job so the scheduler records it as failed and an operator is told. Every product
// that could be paid has already been paid and committed by this point — this reports what
// happened, it does not undo it.
// Fails the job so the scheduler records it as failed and an operator is told. Every report
// that could be paid has already been paid and committed by this point.
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
+ failures.size() + " product(s): " + String.join("; ", failures));
+ failures.size() + " report(s): " + String.join("; ", failures));
}
}
/**
* Posts every approved interest posting report dated on or before the date.
*
* <p>
* Each report posts in a new transaction of its own. That is required, not a preference: this is
* called from the day close's after-commit callback, where the closing transaction has finished
* but is still bound to the thread, and joining it would write nothing that ever commits. It also
* keeps one product's failure from rolling back another's payment. A report that fails stays
* APPROVED with the reason recorded, and the nightly run tries it again.
*
* @return one message per report that could not be posted
*/
public List<String> postApprovedReports(final LocalDate upToDate) {
return postReports(reportService.approvedReportIds(upToDate));
}
/**
* Posts the given approved reports, each in a new transaction of its own (see
* {@link #postApprovedReports}). Used by the day close for one branch's reports.
*
* @return one message per report that could not be posted
*/
public List<String> postReports(final List<Long> reportIds) {
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final List<String> failures = new java.util.ArrayList<>();
for (final Long reportId : reportIds) {
final Map<String, Object> report = jdbcTemplate.queryForMap(
"SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
final long productId = ((Number) report.get("savings_product_id")).longValue();
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
try {
final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
} catch (final RuntimeException e) {
log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
newTransaction.executeWithoutResult(status -> {
reportService.recordPostingFailure(reportId, e.getMessage());
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE report_id = ?
""", truncate(e.getMessage()), reportId);
updateSchedule(productId, postingDate, "FAILED");
});
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
}
}
return failures;
}
/**
* Releases this job's own currently_running flag if an earlier run left it set.
*
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
carriedInterest = backfill.cumulativeInterest();
} else {
final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance");
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
// A cycle is finished either because it was paid out or because its end date has gone
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
// run was missed, or the account was accrued again after a break - and treating it as
// unfinished would leave the row in a cycle that ended before the day it records.
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
// The new cycle may already be days old: the business date moved on by more than a
// day, or nights were missed. Those days earned too. Starting the cycle at zero and
// counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
// October. They are replayed from the account's transactions, as a first row is, and
// the opening balance comes from the same replay so money moved in the gap counts.
if (cycleStart.isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
openingBalance = gap.closingBalance();
carriedInterest = gap.cumulativeInterest();
} else {
openingBalance = (BigDecimal) prev.get("closing_balance");
carriedInterest = BigDecimal.ZERO;
}
} else {
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
// So the gap is replayed from the account's own transactions, the same way a first
// row is built, and the result stands in for the running total the ledger would be
// holding had it run every night.
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
// The replay's balance, not the stale row's: deposits made during the gap were
// otherwise left out of every balance from here on.
openingBalance = gap.closingBalance();
carriedInterest = gap.cumulativeInterest();
} else {
openingBalance = (BigDecimal) prev.get("closing_balance");
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
}
}
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
if (!cycleStart.isBefore(upTo)) {
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
// No day of the cycle to replay — it opens today — but the money held going into it is
// still there. Returning a zero balance made every re-accrual on a cycle's first day (a
// deposit, or the noon report, on the 1st) open the account at nil: an account holding
// 11,503.15 then earned only on what was deposited that day.
return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
}
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
@@ -0,0 +1,677 @@
/**
* 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.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* The interest posting report: what each savings account of one branch will be paid on its product's
* posting date, put in front of that branch before any money moves.
*
* <p>
* One report per branch and product. On a posting date the noon job generates one for every branch
* holding accounts with interest due; a user of that branch may generate or regenerate it at any time
* — the previous report is superseded, not overwritten — and approves it. The branch's approved
* reports are posted when that branch closes its day, and until they are approved it cannot close.
* The head office closes last, once every branch open that day has closed.
*
* <p>
* Only the branch acts on its own reports. The single exception is a branch that did not open that
* day: the head office may then generate and approve its report on its behalf, and posts it at the
* head office's own close. Every entry is still raised in the branch the customers belong to.
*
* <p>
* The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
* two decimals, with the paise the rounding loses given to the largest balance so the report's total
* is the branch's pooled interest exactly.
*/
@Service
@Slf4j
public class SavingsInterestPostingReportService {
public static final String GENERATED = "GENERATED";
public static final String APPROVED = "APPROVED";
public static final String POSTED = "POSTED";
public static final String SUPERSEDED = "SUPERSEDED";
private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
/** m_savings_account.status_enum for an active account. */
private static final int ACTIVE = 300;
/** A product with interest due in one branch. */
private record DueKey(Long productId, Long officeId) {}
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
private final TransactionTemplate transactionTemplate;
public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.accrualService = accrualService;
this.postingService = postingService;
// Always a transaction of its own. The noon job runs inside the batch step's transaction, and
// generating in it would read the accrual through that uncommitted transaction and hold its
// row locks for the whole run; a report committed on its own is also visible to the screen at once.
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
// ── generation ───────────────────────────────────────────────────────────────────────────────
/**
* The noon job: brings the ledger up to the business date, then generates a report for every
* branch and product with interest due that does not already have one.
*
* <p>
* Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
* say — does not stop the others. The failures are raised together at the end so the job is
* recorded as failed.
*
* @return the ids of the reports generated
*/
public List<Long> generateDue(final LocalDate postingDate) {
// So each account's figure includes the posting date itself, on the balance it holds now.
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
final List<Long> generated = new ArrayList<>();
final List<String> failures = new ArrayList<>();
for (final DueKey due : dueKeys(postingDate, null)) {
if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
continue;
}
try {
generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
} catch (final RuntimeException e) {
log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
due.officeId(), postingDate, e);
failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
}
}
if (!failures.isEmpty()) {
throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
+ failures.size() + " report(s): " + String.join("; ", failures));
}
return generated;
}
/**
* Generates the report afresh for one branch and product, on the user's request.
*
* <p>
* Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
* date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
* has moved. An approved one can, and has to be approved again.
*/
public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (postingDate.isAfter(businessDate)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
"The report for " + postingDate + " cannot be generated before that date.");
}
authorize(user, officeId, businessDate);
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
}
/**
* Approves a generated report. A user of the report's branch approves it; the head office may
* approve it only for a branch that did not open that day, and the report records that it did.
*/
public void approve(final Long reportId, final AppUser user) {
final List<Map<String, Object>> found = jdbcTemplate
.queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
"Interest posting report " + reportId + " does not exist.");
}
final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
final int approved = jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report
SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
WHERE id = ? AND status = 'GENERATED'
""", user.getId(), onBehalf, reportId);
if (approved == 0) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
"Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
+ "regenerated or posted already.");
}
}
// ── day close ────────────────────────────────────────────────────────────────────────────────
/**
* What stops one branch from closing the day: a product with interest due in the branch and no
* approved (or posted) report for the date, and any older report of the branch still awaiting
* approval — one left from a day the branch did not open. A report that was never generated
* blocks the close too.
*/
public List<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
final Set<String> blocking = new LinkedHashSet<>();
for (final DueKey due : dueKeys(businessDate, officeId)) {
final Long settled = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
""", Long.class, due.productId(), officeId, businessDate);
if (settled == null || settled == 0) {
blocking.add(productName(due.productId()) + " (" + businessDate + ")");
}
}
for (final Map<String, Object> pending : jdbcTemplate.queryForList("""
SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
ORDER BY r.posting_date
""", officeId, businessDate)) {
blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
}
return new ArrayList<>(blocking);
}
/** The branch's approved reports, posted when it closes the day. */
public List<Long> approvedReportIds(final Long officeId, final LocalDate upToDate) {
return jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
ORDER BY posting_date, savings_product_id
""", Long.class, officeId, upToDate);
}
/**
* Approved reports of branches that did not open on the date — what the head office posts at its
* own close, the branches having no close of their own that day.
*/
public List<Long> approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
return jdbcTemplate.queryForList("""
SELECT r.id FROM m_savings_interest_posting_report r
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
ORDER BY r.posting_date, r.office_id, r.savings_product_id
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
}
/** Every approved report still waiting, for the nightly safety net. */
public List<Long> approvedReportIds(final LocalDate upToDate) {
return jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE status = 'APPROVED' AND posting_date <= ?
ORDER BY posting_date, office_id, savings_product_id
""", Long.class, upToDate);
}
/** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
public void recordPostingFailure(final Long reportId, final String reason) {
jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
}
// ── reading ──────────────────────────────────────────────────────────────────────────────────
/**
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
* head office user, is every other branch (or one, with officeId). canAct says whether this user
* may regenerate or approve the report, and blockedReason why not.
*/
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
final String scope, final AppUser user) {
final Long userOffice = officeOf(user);
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
final Long branch = others ? officeId : userOffice;
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
LEFT JOIN m_appuser au ON au.id = r.approved_by
WHERE 1 = 1
""");
final List<Object> params = new ArrayList<>();
if (postingDate != null) {
sql.append(" AND r.posting_date = ?");
params.add(postingDate);
}
if (status != null && !status.isBlank()) {
sql.append(" AND r.status = ?");
params.add(status.trim().toUpperCase());
} else {
sql.append(" AND r.status <> 'SUPERSEDED'");
}
if (productId != null) {
sql.append(" AND r.savings_product_id = ?");
params.add(productId);
}
if (branch != null) {
sql.append(" AND r.office_id = ?");
params.add(branch);
}
if (others) {
sql.append(" AND r.office_id <> ?");
params.add(userOffice);
}
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> out = new LinkedHashMap<>(row);
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
out.put("canAct", blocked == null);
out.put("blockedReason", blocked);
rows.add(out);
}
return rows;
}
public Map<String, Object> detail(final Long reportId) {
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
o.name AS "officeName", r.posting_date AS "postingDate",
r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
LEFT JOIN m_appuser au ON au.id = r.approved_by
LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
AND em.financial_account_type = 3 AND em.payment_type IS NULL
LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
WHERE r.id = ?
""", reportId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
"Interest posting report " + reportId + " does not exist.");
}
final Map<String, Object> report = new LinkedHashMap<>(found.get(0));
report.put("lines", jdbcTemplate.queryForList("""
SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
FROM m_savings_interest_posting_report_line l
WHERE l.report_id = ?
ORDER BY l.account_no
""", reportId));
return report;
}
/**
* What each savings account has earned so far in its current cycle — what it would be paid if
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
* brought up to the business date (which moves no money) so the figure includes today.
*
* <p>
* A branch user sees their own branch; a head office user every branch, or one with officeId.
*/
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
final Long userOffice = officeOf(user);
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
JOIN m_savings_product p ON p.id = d.savings_product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE p.deposit_type_enum = 100
AND sa.status_enum = ?
AND d.savings_transaction_id IS NULL
""");
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
if (branch != null) {
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
params.add(branch);
}
sql.append(" ORDER BY o.name, p.name, sa.account_no");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* Branch and product interest for the head office: per report, how many customers and how much
* interest, and where it stands. Superseded reports are left out — each line is the report that
* counts for that branch, product and date.
*/
public List<Map<String, Object>> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
FROM m_savings_interest_posting_report r
JOIN m_savings_product p ON p.id = r.savings_product_id
LEFT JOIN m_office o ON o.id = r.office_id
LEFT JOIN m_appuser au ON au.id = r.approved_by
WHERE r.status <> 'SUPERSEDED'
""");
final List<Object> params = new ArrayList<>();
if (fromDate != null) {
sql.append(" AND r.posting_date >= ?");
params.add(fromDate);
}
if (toDate != null) {
sql.append(" AND r.posting_date <= ?");
params.add(toDate);
}
if (productId != null) {
sql.append(" AND r.savings_product_id = ?");
params.add(productId);
}
sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
* before they had anything due, or failed.
*
* <p>
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
* lists the other branches — each with canAct, and where the head office may not act for it, why.
*/
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
final List<Map<String, Object>> out = new ArrayList<>();
// A future date has nothing due yet, and offering to generate for it would label this cycle's
// part-earned interest with a date it has not reached.
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
if (postingDate.isAfter(businessDate)) {
return out;
}
final Long userOffice = officeOf(user);
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
if (others == due.officeId().equals(userOffice)) {
continue;
}
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("productId", due.productId());
row.put("productName", productName(due.productId()));
row.put("officeId", due.officeId());
row.put("officeName", officeName(due.officeId()));
row.put("postingDate", postingDate.toString());
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
row.put("canAct", blocked == null);
row.put("blockedReason", blocked);
out.add(row);
}
}
return out;
}
// ── who may act ──────────────────────────────────────────────────────────────────────────────
public boolean isHeadOffice(final Long officeId) {
final List<Long> parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
return !parent.isEmpty() && parent.get(0) == null;
}
/**
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
*/
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
final Long rows = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_branch_day_register
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
""", Long.class, officeId, businessDate, businessDate);
return rows != null && rows > 0;
}
/** The branches, other than those given, that still have a day open on or before the date. */
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
final StringBuilder sql = new StringBuilder("""
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
WHERE r.closed_at IS NULL AND r.business_date <= ?
""");
final List<Object> params = new ArrayList<>(List.of(businessDate));
for (final Long exclude : excludeOfficeIds) {
sql.append(" AND r.office_id <> ?");
params.add(exclude);
}
sql.append(" ORDER BY o.name");
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
}
/**
* Null where the user may generate or approve the branch's report, otherwise why not.
*
* <p>
* A branch always acts for itself. The head office acts for another branch only when that branch
* has not opened at all, and only once every branch that did open has closed — the head office's
* own close being the last of the day, so nothing is taken out of a branch that may still open.
*/
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
if (userOffice == null) {
return "No branch is set for this user.";
}
if (userOffice.equals(reportOffice)) {
return null;
}
final String branch = officeName(reportOffice);
if (!isHeadOffice(userOffice)) {
return "Only " + branch + " can generate or approve its own interest posting report.";
}
if (branchOpenedOn(reportOffice, businessDate)) {
return branch + " is open — it generates and approves its own report.";
}
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
if (!stillOpen.isEmpty()) {
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
+ String.join(", ", stillOpen) + ".";
}
return null;
}
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
}
/** @return true where the head office is acting on a branch's behalf */
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
final Long userOffice = officeOf(user);
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
if (blocked != null) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
}
return !reportOffice.equals(userOffice);
}
private String officeName(final Long officeId) {
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
}
private static Long officeOf(final AppUser user) {
return user == null || user.getOffice() == null ? null : user.getOffice().getId();
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
/**
* Branch and product pairs with interest due on or before the date: a savings product on the
* daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
* accounts belonging to that branch.
*/
private List<DueKey> dueKeys(final LocalDate postingDate, final Long officeId) {
final StringBuilder sql = new StringBuilder("""
SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
FROM m_savings_daily_interest d
JOIN m_savings_product p ON p.id = d.savings_product_id
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
WHERE p.deposit_type_enum = 100
AND sa.status_enum = ?
AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
""");
final List<Object> params = new ArrayList<>(List.of(ACTIVE, postingDate));
if (officeId != null) {
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
params.add(officeId);
}
sql.append(" ORDER BY 2, 1");
return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
.map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
.toList();
}
private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
final List<Long> ids = jdbcTemplate.queryForList("""
SELECT id FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
""", Long.class, productId, officeId, postingDate);
return ids.isEmpty() ? null : ids.get(0);
}
private String productName(final Long productId) {
return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
}
/** Runs inside the caller's transaction. */
private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT id, status FROM m_savings_interest_posting_report
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
FOR UPDATE
""", productId, officeId, postingDate);
for (final Map<String, Object> report : previous) {
if (POSTED.equals(report.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
"Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
+ " regenerated.");
}
}
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
""", productId, officeId, postingDate);
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest,
sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND sa.status_enum = ?
AND COALESCE(c.office_id, g.office_id) = ?
ORDER BY d.savings_account_id
""", productId, postingDate, ACTIVE, officeId);
if (due.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
"No interest is due for this product in this branch on " + postingDate + ".");
}
// The GL accounts as the product maps them now; a product missing one is refused here, before
// anybody approves figures that could never be posted.
final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
// Round each customer's share, then make the rounded shares add up to the rounded pool: the
// residual goes to the largest balance, where a paisa is proportionally smallest.
BigDecimal grossTotal = BigDecimal.ZERO;
BigDecimal roundedTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
row.put("rounded_interest", rounded);
grossTotal = grossTotal.add(gross);
roundedTotal = roundedTotal.add(rounded);
}
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal residual = pooled.subtract(roundedTotal);
if (residual.signum() != 0) {
final Map<String, Object> largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance")))
.orElseThrow();
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
}
final Long reportId = jdbcTemplate.queryForObject("""
INSERT INTO m_savings_interest_posting_report
(savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
paid_gl_account_id, generation_mode, generated_by, generated_at)
VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
for (final Map<String, Object> row : due) {
final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_report_line
(report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
balance_after, ledger_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
before, before.add(interest), row.get("ledger_id"));
}
log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
postingDate, mode, pooled, due.size());
return reportId;
}
}
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -29,6 +28,8 @@ 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.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
* This settles the debt the accrual has already recognised: the product's total moves out of the
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
* and only then to the members:
* The product's total is raised as an expense and credited to the liability the product names as its
* Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
* then to the members:
*
* <pre>
* Dr Savings Interest Payable (liability, from the product)
* Cr Savings Interest Paid on its way out
* Dr Interest On Savings (expense, from the product) cash-accounted products only
* Cr Savings Interest Payable (liability, from the product)
*
* Dr Savings Interest Payable
* Cr Savings Interest Paid (liability clearing) on its way out
*
* Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) in the member's hands
* </pre>
*
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
* this is the settlement of what that recognition already booked - raising the expense again at
* payout would put the same interest on the P&amp;L twice.
* The payable and paid accounts each close the posting at nil, so the only lasting effect is the
* expense against the members' balances, and a liability report reading the payable is unaffected.
* The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
* Accounting tab, which is where a bank picks the account interest is owed from.
*/
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
/** m_savings_product.accounting_type for cash-based accounting. */
private static final int ACCOUNTING_TYPE_CASH = 2;
private final JdbcTemplate jdbcTemplate;
/** The payable and paid accounts a product's interest passes through. */
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
/**
* Posts one product's interest for one posting date.
*
* @return the amount distributed, or zero if there was nothing to do
* The payable and paid accounts, as the product maps them now.
*
* <p>
* REQUIRED, not REQUIRES_NEW.
* The product's Accounting tab is the authority: the standard Interest Payable row first, then
* the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
* back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
* leaves the two-legged posting a product without these mappings has always had.
*/
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
// All three are required. Falling back to Savings Control when one was left blank posted the
// interest straight from expense to the member with no payable or paid leg at all, and nobody
// could tell from the entries that the product had simply lost its mappings.
mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
if (payableGl == null || paidGl == null) {
final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
productId);
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
+ productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
+ " GL mapped. Set it under the product's Accounting and generate the report again.");
}
return new PostingGlAccounts(payableGl, paidGl);
}
/**
* Posts one approved interest posting report, paying each account exactly what was approved.
*
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
* the orchestrator calls this outside any transaction of its own, so each product opens and
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
* running and waits for locks that transaction may hold. The accrual step immediately before
* writes every account's ledger row, and this method reads and updates those same rows: with
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
* and the job hung with nothing in the log and its currently_running flag left set.
* <p>
* A report belongs to one branch and one product, and every leg it raises is in that branch, so
* the branch's interest cost sits in its own P&amp;L and its payable and paid accounts close at nil
* in its own books:
*
* <pre>
* Dr Interest On Savings (branch total) → Cr Savings Interest Payable
* Dr Savings Interest Payable → Cr Savings Interest Paid
* Dr Savings Interest Paid (once) → Cr each customer of the branch
* </pre>
*
* <p>
* Nothing is recomputed. The report's lines are the authority: interest that accrued after the
* report was generated is not added, because the approver signed off on these figures and not on
* others. The payable and paid accounts are those recorded on the report when it was generated.
*
* <p>
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
* branch that was shut on its posting date posts when it next closes, into a day that is still
* open, and the narration names the cycle the interest is for.
*
* <p>
* The whole report settles in one transaction, so a failure leaves nobody half paid and the report
* still APPROVED for the next attempt.
*
* @return the amount distributed
*/
@Transactional
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
final Long completed = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
""", Long.class, productId, postingDate);
if (completed != null && completed > 0) {
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
return BigDecimal.ZERO;
public BigDecimal postReport(final long reportId) {
final List<Map<String, Object>> reports = jdbcTemplate.queryForList("""
SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
paid_gl_account_id
FROM m_savings_interest_posting_report
WHERE id = ?
FOR UPDATE
""", reportId);
if (reports.isEmpty()) {
throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
}
final Map<String, Object> report = reports.get(0);
if (!"APPROVED".equals(report.get("status"))) {
throw new IllegalStateException(
"Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
}
final long productId = ((Number) report.get("savings_product_id")).longValue();
final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final List<Map<String, Object>> lines = jdbcTemplate.queryForList("""
SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
sa.currency_code
FROM m_savings_interest_posting_report_line l
JOIN m_savings_account sa ON sa.id = l.savings_account_id
WHERE l.report_id = ?
ORDER BY l.savings_account_id
""", reportId);
if (lines.isEmpty()) {
throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
}
final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
: ((Number) lines.get(0).get("office_id")).longValue();
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
final Long payableGl = report.get("payable_gl_account_id") != null
? ((Number) report.get("payable_gl_account_id")).longValue()
: current.payableGl();
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
: current.paidGl();
final String currency = (String) lines.get(0).get("currency_code");
// One per report, and deterministic: a retried posting carries the same reference as the legs a
// failed attempt rolled back, and two branches posting the same product never share one.
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
final String narration = "Savings interest for cycle ending " + cycleDate;
// Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
// credits it on a cash-accounted product — the daily accrual writes no journal entry. An
// accrual-accounted product has already done this through its accrual postings.
if (isCashAccounted(productId)) {
final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
}
// Every account whose posting date has arrived or passed and has not yet been paid.
//
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
// run on was never paid at all — the cycle simply rolled over with the interest carried into
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
// to behave when a night is missed.
//
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
// somebody a second time.
//
// A savings account has no office of its own: it belongs to a client, or to a group when
// there is no client, and the office is theirs. The journal entries have to be raised
// against that office or they land in the wrong branch's books.
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
d.cycle_cumulative_interest AS gross_interest,
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
sa.currency_digits
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
ORDER BY d.savings_account_id
""", productId, postingDate);
if (due.isEmpty()) {
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
return BigDecimal.ZERO;
}
final long runId = openRun(productId, postingDate, due.size());
// Round each member's share, then make the rounded shares add up. The residual is the
// difference between what the pool holds and what the members would otherwise receive.
BigDecimal grossTotal = BigDecimal.ZERO;
BigDecimal roundedTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
row.put("rounded_interest", rounded);
grossTotal = grossTotal.add(gross);
roundedTotal = roundedTotal.add(rounded);
}
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
final BigDecimal residual = pooled.subtract(roundedTotal);
if (residual.compareTo(BigDecimal.ZERO) != 0) {
// To the largest balance: the account where a paisa is proportionally smallest.
final Map<String, Object> largest = due.stream()
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
log.info("Savings interest posting: residual {} assigned to account {}", residual,
largest.get("savings_account_id"));
}
// The three accounts the bill passes through on its way to the members. Every one is read
// from the product's own configuration, so a bank on its own chart of accounts is posted to
// the accounts it chose. No GL code is named in this class.
//
// The product's Accounting tab is the authority, and it is consulted first for all three:
// Interest On Savings for the expense, and the standard Interest Payable row for the
// payable. Those are the rows a bank actually fills in on the product screen.
//
// The reserved names in the product's other GL mappings are consulted only after that, as a
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
// which has no standard row of its own.
//
// Where a product sets none of them the bill still has somewhere to go: the payable falls
// back to the product's Savings Control (interest owed is a liability either way), and the
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
// posting a product without these mappings has always had.
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code");
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
// and the resumed legs have to carry the same reference as the legs already posted, or one
// interest posting ends up split across two transaction ids.
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
productId);
// Stage 1: the debt the accrual has been building moves out of the payable and into the
// account the payout is made from, so what is still merely owed and what is on its way to
// the members are two separate balances rather than one.
//
// The interest expense itself is not raised here. It is recognised as the interest accrues,
// day by day, and posting is the settlement of that recognised debt — raising the expense
// again at payout would count the same interest twice on the P&L.
//
// Skipped where the product names no separate paid account: both legs then resolve to the
// payable and this would debit and credit one account for the same amount, saying nothing.
final boolean payableIsPaid = payableGl.equals(paidGl);
final boolean stage1Applies = !payableIsPaid;
// Stage 1: the total moves out of the payable and into the account the payout is made from.
// Skipped where both are the same account, which would debit and credit it for nothing.
final boolean stage1Applies = !payableGl.equals(paidGl);
if (stage1Applies) {
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
}
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
// from there instead — debiting the paid account would take the money out of an account
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
// Stage 2: out to the members, one at a time.
// Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
// carries the report's one transaction id, and every credit names its account and interest
// transaction, so who was paid what reads back from the journal alone.
journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
BigDecimal distributed = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final long accountId = ((Number) row.get("savings_account_id")).longValue();
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
// The member's own branch, which need not be the branch the pool was raised in.
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance);
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId);
// closing_balance is raised here because the interest transaction has just been written
// and the accrual for this day has already run: nothing else will fold it in before the
// next day reads this row as its opening.
jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
closing_balance = closing_balance + ?
WHERE id = ?
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
// Both interest columns move together, and that is not a duplicate.
//
// total_interest_earned_derived is the interest the account has earned to date;
// total_interest_posted_derived is how much of that has been paid into the balance.
// Screens and validate()'s own check read the unposted interest as earned - posted, so
// raising only `posted` drives that difference negative: an account paid 26.52 with
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
// showed exactly that. The accrual writes pending interest to the ledger and never to
// `earned`, so this posting is the only place `earned` is realised.
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
WHERE id = ?
""", amount, amount, amount, accountId);
distributed = distributed.add(amount);
for (final Map<String, Object> line : lines) {
distributed = distributed
.add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
}
// The whole point of the two control accounts: if this does not hold, nothing commits.
// If the credits do not add up to the debit, nothing commits.
if (pooled.compareTo(distributed) != 0) {
throw new IllegalStateException(String.format(
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
productId, postingDate, pooled, distributed));
"Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
}
return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
}
/**
* One customer's credit: the interest transaction, the Savings Control credit naming the account,
* and the account, ledger and report line brought up to date. The branch debit is raised by the
* caller.
*
* @return the amount credited, zero for a line with nothing to pay
*/
private BigDecimal payMember(final Map<String, Object> line, final long productId, final long officeId,
final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
final String narration, final long runId) {
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
final long accountId = ((Number) line.get("savings_account_id")).longValue();
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
// payment, so this holds even where one account has two cycles posted on the same day.
if (line.get("savings_transaction_id") != null) {
throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
}
// The balance as it stands now, not as the report showed it: money moved since the report
// was generated, and the transaction's running balance has to follow the account.
final BigDecimal currentBalance = jdbcTemplate.queryForObject(
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
final BigDecimal newBalance = currentBalance.add(amount);
final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
// Settles the ledger row where it still holds the cycle the report was drawn from. Where the
// nightly run has already moved it into the next cycle — a branch that was shut on its posting
// date and posts days later — that row is the new cycle's and must not be marked paid; it only
// takes the money, because the next day's accrual opens from its closing balance.
final int settled = jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
closing_balance = closing_balance + ?
WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
""", amount, transactionId, runId, amount, accountId, cycleDate);
if (settled == 0) {
jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
amount, accountId);
}
// Earned is only ever raised to what has now been posted, never added to: the engine may
// already have counted this interest as earned when the account was last recalculated,
// and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
// unposted interest as earned - posted, so earned must not fall below posted either.
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
COALESCE(total_interest_posted_derived, 0) + ?)
WHERE id = ?
""", amount, amount, amount, accountId);
jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
transactionId, ((Number) line.get("line_id")).longValue());
return amount;
}
private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
final BigDecimal distributed, final long runId, final int accountCount) {
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_run
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
""", pooled, distributed, residual, runId);
""", pooled, distributed, runId);
jdbcTemplate.update("""
UPDATE m_savings_interest_posting_report
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
WHERE id = ?
""", runId, reportId);
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
postingDate, distributed, due.size(), residual);
log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
cycleDate, distributed, accountCount);
return distributed;
}
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
/** One run per report. A retry of the same report reuses its run. */
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
final int accountCount) {
jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_run
(savings_product_id, posting_date, account_count, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
(report_id, savings_product_id, office_id, posting_date, account_count, status)
VALUES (?, ?, ?, ?, ?, 'PENDING')
ON CONFLICT (report_id) DO UPDATE SET
account_count = EXCLUDED.account_count, status = 'PENDING',
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
""", productId, postingDate, accountCount);
return jdbcTemplate.queryForObject("""
SELECT id FROM m_savings_interest_posting_run
WHERE savings_product_id = ? AND posting_date = ?
""", Long.class, productId, postingDate);
""", reportId, productId, officeId, cycleDate, accountCount);
return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
}
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
""", Long.class, accountId, date, INTEREST_POSTING);
}
/** The member's own leg: the product's Savings Control account, credited per account. */
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
final long transactionId) {
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
}
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
}
/**
* One journal entry leg.
*
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) {
final Long transactionId, final String narration) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
debit ? "Savings interest posting" : "Savings interest posted",
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
systemUserId(), systemUserId(), date);
}
@@ -406,8 +424,8 @@ public class SavingsPooledInterestPostingService {
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
*/
private Long systemUserId() {
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
return ids.isEmpty() ? 1L : ids.get(0);
}
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
}
/** The first configured account of those given, in order of preference. */
private boolean isCashAccounted(final long productId) {
final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
Integer.class, productId);
return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
}
private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
@@ -0,0 +1,214 @@
/**
* 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.dailyinterest.api;
import com.google.gson.Gson;
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.core.MediaType;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
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.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* The savings interest posting reports, per branch and product: list, detail, generate, approve, the
* day-close check, and the head office's branch-wise summary.
*
* <p>
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
* day, so there is exactly one way money leaves the payable.
*/
@Path("/v1/savingsinterestpostingreports")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class SavingsInterestPostingReportApiResource {
private static final String RESOURCE = "SAVINGSINTERESTREPORT";
private final SavingsInterestPostingReportService reportService;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
@GET
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
@QueryParam("scope") final String scope) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
}
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
@GET
@Path("/context")
public String userContext() {
final AppUser user = context.authenticatedUser();
final Long officeId = user.getOffice().getId();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
result.put("officeName", user.getOffice().getName());
result.put("headOffice", reportService.isHeadOffice(officeId));
return gson.toJson(result);
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
@GET
@Path("/due-without-report")
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
}
/**
* What stops a branch closing the business day: its own reports due and not yet approved. The
* day-close screen reads it to warn before the close is attempted.
*/
@GET
@Path("/day-close-check")
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
final AppUser user = context.authenticatedUser();
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final List<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("businessDate", businessDate.toString());
result.put("canClose", blocking.isEmpty());
result.put("pendingProducts", blocking);
return gson.toJson(result);
}
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
@GET
@Path("/preview")
public String preview(@QueryParam("officeId") final Long officeId) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
}
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
@GET
@Path("/summary")
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("productId") final Long productId) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
if (!reportService.isHeadOffice(user.getOffice().getId())) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
"The branch-wise interest summary is for the head office.");
}
return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
}
@GET
@Path("/{reportId}")
public String detail(@PathParam("reportId") final Long reportId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
final Map<String, Object> report = plain(reportService.detail(reportId));
@SuppressWarnings("unchecked")
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
report.put("lines", lines.stream().map(this::plain).toList());
return gson.toJson(report);
}
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public String command(@QueryParam("command") final String command, final String body) {
if (!"regenerate".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
"Unsupported command: " + command);
}
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
final var root = fromJsonHelper.parse(body);
final Long productId = fromJsonHelper.extractLongNamed("productId", root);
final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
if (productId == null) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
}
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
final Long reportId = reportService.regenerate(productId, officeId, date, user);
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
}
/** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
@POST
@Path("/{reportId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
if (!"approve".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
"Unsupported command: " + command);
}
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
reportService.approve(reportId, user);
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
}
private static LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return LocalDate.parse(raw.trim());
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
"Dates are expected as yyyy-MM-dd, not " + raw);
}
}
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
private Map<String, Object> plain(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
row.forEach((key, value) -> {
if (value instanceof java.sql.Date d) {
out.put(key, d.toLocalDate().toString());
} else if (value instanceof Timestamp t) {
out.put(key, t.toLocalDateTime().toString());
} else {
out.put(key, value);
}
});
return out;
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SavingsInterestPostingReportConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
.tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
}
@Bean
public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
.start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
.build();
}
}
@@ -0,0 +1,45 @@
/**
* 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.dailyinterest.jobs;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Noon on the business date: generates the interest posting report for every savings product whose
* interest falls due, so it can be reviewed and approved before the branches close the day.
*/
@RequiredArgsConstructor
@Component
public class SavingsInterestPostingReportTasklet implements Tasklet {
private final SavingsInterestPostingReportService reportService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
reportService.generateDue(DateUtils.getBusinessLocalDate());
return RepeatStatus.FINISHED;
}
}
@@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator {
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
DepositsApiConstants.prematureClosureReasonIdParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
@@ -245,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
} else if (accountOnClosureType.isTransferToGl()) {
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"transfer.to.gl.not.allowed",
"Transfer to a GL account is only supported for fixed-deposit premature closure");
}
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
// and is refused here as well -- otherwise a direct API call could still take the cash route.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"withdraw.deposit.not.allowed",
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
}
}
@@ -19,10 +19,14 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -36,6 +40,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
@@ -46,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
@@ -65,6 +71,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -73,6 +80,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -93,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -812,6 +822,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
Long savingsTransactionId = null;
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
// fresh here since the preview and the actual submit are two separate requests.
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
// closure always matches what the preview showed for the same reason.
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
// post interest
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
@@ -831,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
} else if (closureType.isTransferToGl()) {
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
final BigDecimal proceeds = account.getAccountBalance();
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
// net posting as Dr Savings Control / Cr chosen GL.
final boolean isInternalTransfer = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
isRegularTransaction, isInternalTransfer);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
command.stringValueOfParameterNamed(transferDescriptionParamName));
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
@@ -848,6 +884,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
* Dr the Liability Transfer account / Cr that GL account.
* <p>
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
* reconciles exactly like any other manual entry.
*/
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
final LocalDate closedDate, final String description) {
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
final JsonObject debitEntry = new JsonObject();
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
debitEntry.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debitEntry);
final JsonObject creditEntry = new JsonObject();
creditEntry.addProperty("glAccountId", toGlAccountId);
creditEntry.addProperty("amount", amount);
final JsonArray credits = new JsonArray();
credits.add(creditEntry);
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
if (description != null && !description.isBlank()) {
comments.append(" -- ").append(description);
}
final String dateFormat = "dd MMMM yyyy";
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", account.officeId());
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
payload.addProperty("dateFormat", dateFormat);
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", account.getCurrency().getCode());
payload.addProperty("comments", comments.toString());
payload.add("debits", debits);
payload.add("credits", credits);
final String json = payload.toString();
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
null, null, null, null, null, null, null, null, null, null, null, null, null);
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
}
/**
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
* to clear it.
* <p>
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
* collapse onto whichever account that mapping names.
* <p>
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
* closure's money somewhere nobody chose.
*/
private Long liabilityTransferGlAccountId() {
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
if (glAccountIds.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
+ " proceeds cannot be routed to a GL account. Map it first.",
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
}
return glAccountIds.get(0);
}
/**
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
protected InterestRateChartAssembler chartAssembler;
@Transient
private ConfigurationDomainService configurationDomainService;
/**
* Set once per request, immediately before a premature-closure preview or the real closure, from the
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
* a one-time decision for this specific closure rather than a product/account setting. The product's
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
* every other premature closure of this same product still applies its penalty as normal; this only
* suppresses it for the one request that explicitly asked to waive it.
*/
@Transient
private boolean waivePreClosurePenaltyForThisRequest;
protected FixedDepositAccount() {
//
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
if (this.chart != null) {
boolean applyPreMaturePenalty = false;
BigDecimal penalInterest = BigDecimal.ZERO;
LocalDate depositCloseDate = calculateMaturityDate();
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
// overload), so a closure's freshly-recomputed total always matches what the account was
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
// simple, one rate for the account's whole life.
final LocalDate depositCloseDate = calculateMaturityDate();
if (isPreMatureClosure) {
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
applyPreMaturePenalty = true;
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
.depositPreClosureDetail().preClosurePenalInterestOnType();
if (preClosurePenalInterestOnType.isWholeTerm()) {
depositCloseDate = interestCalculatedUpto();
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
depositCloseDate = interestPostingUpToDate;
}
}
}
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
transactionsForCurrentTermInterestCalculation(transactions));
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
boolean isUserPosting = false;
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
final boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
transactions);
transactionsForCurrentTermInterestCalculation(transactions));
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
// RBI audit trail: record why, not just that, this closure happened -- see
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
// which resolves to INVALID/null here rather than a misleading "Normal".
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
/*
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
}
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
* same interest calculation this waives a part of. */
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
this.waivePreClosurePenaltyForThisRequest = waive;
}
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
@@ -807,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
Money interestOnMaturity = Money.zero(this.currency);
Money interestOnMaturity = Money.zero(this.currency);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
}
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
return interestOnMaturity;
}
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
@@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
/**
* Interest already posted for the term currently running, NOT the account's whole lifetime.
* <p>
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
* premature closure of the CURRENT term subtracted it from the maturity amount
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
* the term restarted belongs to a term that has already been settled.
*/
private Money totalInterestPosted() {
final boolean renewedTerm = isRenewedTerm();
final LocalDate currentTermStartDate = depositStartDate();
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
if (transaction.isInterestPostingAndNotReversed()) {
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
if (!transaction.isInterestPostingAndNotReversed()) {
continue;
}
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
// previous term's closing interest posting is dated the SAME day the new term starts, so an
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
// the old term's entire interest subtracted from a maturity amount it was never part of.
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
continue;
}
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
}
return interestPostedToDate;
@@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount {
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
/**
* A renewed term's opening balance is seeded straight to the term's own principal (see
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
* and the new one opened in the same stroke), so without this filter it still falls inside the new
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
* understating the very first day's balance by the old term's entire payout, which silently corrupts
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
*/
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
final List<SavingsAccountTransaction> transactions) {
if (!isRenewedTerm()) {
return transactions;
}
final LocalDate currentTermStartDate = depositStartDate();
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
for (final SavingsAccountTransaction transaction : transactions) {
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
scoped.add(transaction);
}
}
return scoped;
}
private LocalDate interestCalculatedUpto() {
LocalDate uptoMaturityDate = calculateMaturityDate();
if (uptoMaturityDate != null) {
@@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
Money interestOnMaturity = Money.zero(this.currency);
Money interestOnMaturity = Money.zero(this.currency);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
}
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
return interestOnMaturity;
}
@Override
@@ -0,0 +1,93 @@
/**
* 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.fdinterest;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Posts approved fixed deposit interest reports, one transaction each.
*
* <p>
* Called as a branch closes its day. A report that cannot be posted does not take the others down
* with it: its failure is written to the report, which stays APPROVED, and reported back to the
* caller so the close can be refused with a reason the branch can act on. Closing again once the
* cause is fixed posts it.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class FixedDepositInterestPostingOrchestrator {
private final JdbcTemplate jdbcTemplate;
private final FixedDepositInterestPostingService postingService;
private final FixedDepositInterestPostingReportService reportService;
private final PlatformTransactionManager transactionManager;
/**
* Posts each report in a transaction of its own.
*
* @return one message per report that could not be posted, empty where all of them were
*/
public List<String> postReports(final List<Long> reportIds) {
final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager);
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
final List<String> failures = new ArrayList<>();
for (final Long reportId : reportIds) {
final Map<String, Object> report = this.jdbcTemplate
.queryForMap("SELECT fd_product_id, posting_date FROM m_fd_interest_posting_report WHERE id = ?", reportId);
final long productId = ((Number) report.get("fd_product_id")).longValue();
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
try {
final BigDecimal paid = newTransaction.execute(status -> this.postingService.postReport(reportId));
log.info("FD interest: report {} for product {} posted {} for {}", reportId, productId, paid, postingDate);
} catch (final RuntimeException e) {
log.error("FD interest: report {} for product {} failed to post for {}", reportId, productId, postingDate, e);
newTransaction.executeWithoutResult(status -> {
this.reportService.recordPostingFailure(reportId, e.getMessage());
this.jdbcTemplate.update("""
UPDATE m_fd_interest_posting_run
SET status = 'FAILED', failure_reason = ?
WHERE report_id = ?
""", truncate(e.getMessage()), reportId);
});
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
}
}
return failures;
}
private static String truncate(final String reason) {
if (reason == null) {
return "unknown failure";
}
return reason.length() > 1000 ? reason.substring(0, 1000) : reason;
}
}
@@ -0,0 +1,527 @@
/**
* 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.fdinterest;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
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.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Pays an approved fixed deposit interest posting report.
*
* <p>
* Nothing is recomputed here. The report's lines are the authority: each deposit is credited exactly
* what was approved for it, and interest that accrued after the report was generated is not added,
* because the approver signed off on these figures and not on others. The payable and paid accounts
* are those recorded on the report when it was generated.
*
* <p>
* The ledger entries are the three stages fixed deposit interest has always posted through, raised
* once for the whole report rather than once per deposit — the same pooling the savings interest
* posting report uses, for the same reason: a bank's interest cost is one figure in its books, not
* one line per customer repeated three times over.
*
* <pre>
* Dr Interest Expense (report total) → Cr Fixed Deposit Interest Payable
* Dr Interest Payable (report total) → Cr Fixed Deposit Interest Paid
* Dr Interest Paid (report total, once)
* Cr Savings Control (per deposit, naming the account and its own amount)
* </pre>
*
* A product that names no paid account posts the two-stage form, and one that names neither falls
* back to the plain Dr Expense / Cr Control pair — pooled the same way, one expense debit against many
* credits. Where one report holds deposits due for more than one period — a deposit owed two unpaid
* cycles, or a branch posting a stale cycle alongside today's — each {@code interestPostingDate}
* pools on its own: the chain above is raised once per date present on the report, not once overall,
* because the narration and the ledger date belong to the cycle, not to the day it happened to post.
*
* <p>
* Where the interest then goes is the product's scheme, and it is not decided here. A
* {@code SPECIAL} deposit carries {@code transfer_interest_to_linked_account}, and the existing
* transfer-interest-to-savings job sweeps the posted interest to the customer's linked savings
* account as it always has; {@code SINGLE} and {@code DOUBLE} keep it in the deposit. Posting is the
* same act for all three — the money reaches the customer's deposit — so this service does one thing
* and the sweep is left where it already works.
*
* <p>
* A deposit can mature between its report being generated and being posted, and a matured deposit's
* last period is settled directly by the maturity job the moment it matures — see
* {@code FixedDepositAccount#postMaturityInterest}, which this report's generation step cannot see
* coming and does not block on. Posting checks for that first: a line whose deposit already carries an
* interest posting for its exact date is linked to what actually paid it rather than paid again, and
* its amount is reconciled off the report's approved total rather than failing the whole posting over
* money that was never going to move twice.
*
* <p>
* The whole report settles in one transaction: a failure leaves nobody half paid and the report still
* APPROVED for the next attempt.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class FixedDepositInterestPostingService {
private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
/** m_savings_account_transaction.transaction_type_enum */
private static final int INTEREST_POSTING = 3;
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
private static final int ENTITY_TYPE_SAVINGS = 2;
/** {@code CashAccountsForSavings.SAVINGS_CONTROL} */
private static final int GL_SAVINGS_CONTROL = 2;
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense interest is charged to. */
private static final int GL_INTEREST_ON_SAVINGS = 3;
/** {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product. */
private static final int GL_INTEREST_PAYABLE = 17;
/**
* The reserved names a deposit product may map its payable and paid accounts under, most specific
* first. The same lists the scheduler reads, so a product configured for the old path needs no
* change for the new one.
*/
private static final String[] PAYABLE_NAMES = { "Fixed Deposit Interest Payable", "Savings Interest Payable",
"Recurring Deposit Interest Payable", "Interest Payable", "Interest Payable Account" };
private static final String[] PAID_NAMES = { "Fixed Deposit Interest Paid", "Savings Interest Paid",
"Recurring Deposit Interest Paid", "Interest Paid", "Interest Paid Account" };
private final JdbcTemplate jdbcTemplate;
/** The payable and paid accounts a product's interest passes through. */
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
/**
* One deposit's credit, after its transaction and balances are written but before the pooled ledger
* legs are raised — grouped with the others of its {@code interestDate} so the whole group is posted
* as one chain.
*/
private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {}
/**
* What one line's posting came to: either a new credit to pool with the rest of its date-group, or
* an amount that turned out to be settled already — a deposit that matured, and had this exact
* period's interest posted directly by the maturity job, between this report being generated and
* being posted. {@code paid} is null in that case, and {@code reconciledAway} carries the amount so
* the report's approved total can be reconciled down by it rather than the whole posting failing a
* balance check over money that was never going to move twice.
*/
private record DepositOutcome(PaidLine paid, BigDecimal reconciledAway) {}
/**
* The payable and paid accounts as the product maps them now, read when a report is generated so a
* product missing one is refused before anybody approves figures that could never be posted.
*
* <p>
* Unlike savings, neither is required: a deposit product that names no payable posts the plain two
* legs it always did, and nulls here say so rather than stopping the report.
*/
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
if (optionalMappedGlAccount(productId, GL_INTEREST_ON_SAVINGS) == null) {
final String productName = this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
productId);
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.gl.not.mapped",
"Fixed deposit product '" + productName + "' has no Interest On Savings GL mapped. Set it under the product's"
+ " Accounting and generate the report again.");
}
Long payableGl = optionalMappedGlAccount(productId, GL_INTEREST_PAYABLE);
if (payableGl == null) {
payableGl = namedGlAccount(productId, PAYABLE_NAMES);
}
return new PostingGlAccounts(payableGl, namedGlAccount(productId, PAID_NAMES));
}
/**
* Posts one approved report, crediting each deposit exactly what was approved for it.
*
* <p>
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
* branch that was shut on its posting date posts when it next closes, into a day that is still
* open, and the narration names the date the interest is for.
*
* @return the amount distributed
*/
@Transactional
public BigDecimal postReport(final long reportId) {
final List<Map<String, Object>> reports = this.jdbcTemplate.queryForList("""
SELECT id, fd_product_id, fd_type, office_id, posting_date, status, total_interest, payable_gl_account_id,
paid_gl_account_id
FROM m_fd_interest_posting_report
WHERE id = ?
FOR UPDATE
""", reportId);
if (reports.isEmpty()) {
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " does not exist");
}
final Map<String, Object> report = reports.get(0);
if (!FixedDepositInterestPostingReportService.APPROVED.equals(report.get("status"))) {
throw new IllegalStateException(
"Fixed deposit interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
}
final long productId = ((Number) report.get("fd_product_id")).longValue();
final long officeId = ((Number) report.get("office_id")).longValue();
final LocalDate interestFor = ((java.sql.Date) report.get("posting_date")).toLocalDate();
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
final BigDecimal approved = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final List<Map<String, Object>> lines = this.jdbcTemplate.queryForList("""
SELECT l.id AS line_id, l.fd_account_id, l.interest_posting_date, l.interest_amount, l.fd_transaction_id,
sa.currency_code
FROM m_fd_interest_posting_report_line l
JOIN m_savings_account sa ON sa.id = l.fd_account_id
WHERE l.report_id = ?
ORDER BY l.interest_posting_date, l.fd_account_id
""", reportId);
if (lines.isEmpty()) {
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " has no deposits to pay");
}
final long runId = openRun(reportId, productId, officeId, interestFor, lines.size());
final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS);
final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL);
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
final Long payableGl = report.get("payable_gl_account_id") != null ? ((Number) report.get("payable_gl_account_id")).longValue()
: current.payableGl();
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
: current.paidGl();
final String currency = (String) lines.get(0).get("currency_code");
// One per report, and deterministic: a retried posting carries the same reference as the legs a
// failed attempt rolled back, and two branches posting the same product never share one.
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.FD_INTEREST_POSTED, postedOn, reportId);
// Each deposit's transaction and balances are settled first; the ledger is raised after, pooled
// by the date its interest belongs to, so a report spanning two cycles raises two chains rather
// than crediting one period's money under the other's date.
final Map<LocalDate, List<PaidLine>> byDate = new LinkedHashMap<>();
BigDecimal distributed = BigDecimal.ZERO;
BigDecimal reconciledAway = BigDecimal.ZERO;
for (final Map<String, Object> line : lines) {
final DepositOutcome outcome = payDeposit(line, officeId, postedOn);
if (outcome.paid() != null) {
byDate.computeIfAbsent(outcome.paid().interestDate(), key -> new ArrayList<>()).add(outcome.paid());
distributed = distributed.add(outcome.paid().amount());
}
reconciledAway = reconciledAway.add(outcome.reconciledAway());
}
// A line reconciled away was never going to move twice: its amount is taken off what this
// posting is expected to distribute rather than failing the whole report over money that is
// already in the customer's deposit by another, older path. What was actually paid here still
// has to add up exactly to what is left.
final BigDecimal expected = approved.subtract(reconciledAway);
if (expected.compareTo(distributed) != 0) {
throw new IllegalStateException(
String.format("Fixed deposit interest posting report %d did not balance: approved %s, reconciled away %s,"
+ " distributed %s", reportId, approved, reconciledAway, distributed));
}
for (final Map.Entry<LocalDate, List<PaidLine>> group : byDate.entrySet()) {
postPooledLedgerLegs(officeId, currency, group.getKey(), postedOn, group.getValue(), reference, expenseGl, payableGl, paidGl,
controlGl);
}
completeReport(reportId, productId, interestFor, approved, distributed, runId, lines.size());
return distributed;
}
/**
* One deposit's interest transaction and balances, settled without raising any ledger entry — the
* caller pools this with the rest of its date-group before the GL is touched.
*
* <p>
* The transaction carries the date the interest engine gave the period, not the day the branch is
* closing. That is not presentation: the engine decides a period has already been paid by looking
* for an interest transaction on exactly that date, so a posting written under the close date would
* be invisible to it and the same period would be computed and paid again at the next run. The
* close date is kept as the submitted-on stamp, which is what the scheduler has always done.
*
* <p>
* Checked first against the deposit's own transactions for one this report did not put there: a
* deposit can mature between its report being generated and being posted, and the maturity job
* settles a matured deposit's last period itself, directly, the moment it matures — see
* {@code FixedDepositAccount#postMaturityInterest}. Where that has already happened for this exact
* date, the line is linked to what actually paid it rather than paying it again.
*/
private DepositOutcome payDeposit(final Map<String, Object> line, final long officeId, final LocalDate postedOn) {
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
final long accountId = ((Number) line.get("fd_account_id")).longValue();
final long lineId = ((Number) line.get("line_id")).longValue();
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return new DepositOutcome(null, BigDecimal.ZERO);
}
final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate();
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
// payment, so this holds even where one deposit has two periods on one report.
if (line.get("fd_transaction_id") != null) {
throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report");
}
final Long existing = findExistingInterestPosting(accountId, interestDate);
if (existing != null) {
log.warn(
"FD interest report line {}: deposit {} already has an interest posting for {} (transaction {}) not raised by this"
+ " report — most likely matured in the meantime. Linking to it instead of posting again.",
lineId, accountId, interestDate, existing);
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", existing, lineId);
return new DepositOutcome(null, amount);
}
// The balance as it stands now, not as the report showed it: money may have moved since the
// report was generated, and the transaction's running balance has to follow the deposit.
final BigDecimal currentBalance = this.jdbcTemplate.queryForObject(
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
final BigDecimal newBalance = currentBalance.add(amount);
final long transactionId = createInterestPostingTransaction(accountId, officeId, interestDate, postedOn, amount, newBalance);
// Earned is only ever raised to what has now been posted, never added to: the engine may
// already have counted this interest as earned, and adding on top of that doubles it. Screens
// read unposted interest as earned - posted, so earned must not fall below posted either.
//
// interest_posted_till_date is what takes the deposit out of the next report's candidates; the
// interest transaction itself is what stops the engine recomputing the same period.
this.jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
COALESCE(total_interest_posted_derived, 0) + ?),
interest_posted_till_date = GREATEST(COALESCE(interest_posted_till_date, ?), ?)
WHERE id = ?
""", amount, amount, amount, interestDate, interestDate, accountId);
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId,
lineId);
return new DepositOutcome(new PaidLine(accountId, amount, interestDate, transactionId), BigDecimal.ZERO);
}
/** A non-reversed interest posting this deposit already carries for this exact date, if any. */
private Long findExistingInterestPosting(final long accountId, final LocalDate interestDate) {
final List<Long> found = this.jdbcTemplate.queryForList("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND transaction_date = ? AND is_reversed = false
ORDER BY id DESC LIMIT 1
""", Long.class, accountId, INTEREST_POSTING, interestDate);
return found.isEmpty() ? null : found.get(0);
}
/**
* One date-group's chain, pooled: the report's cost moves through the fewer-or-more intermediate
* accounts the product's mappings support as one figure, and only the last leg — crediting each
* deposit — is raised per account. The group's own total is what moves, not the report's, so two
* periods on one report each carry their own entries under their own date.
*/
private void postPooledLedgerLegs(final long officeId, final String currency, final LocalDate interestDate, final LocalDate postedOn,
final List<PaidLine> group, final String reference, final Long expenseGl, final Long payableGl, final Long paidGl,
final Long controlGl) {
final String narration = "Fixed deposit interest for " + interestDate;
BigDecimal total = BigDecimal.ZERO;
for (final PaidLine paid : group) {
total = total.add(paid.amount());
}
final boolean hasPayable = payableGl != null && payableGl != 0 && !payableGl.equals(controlGl);
final boolean hasPaid = paidGl != null && paidGl != 0 && !paidGl.equals(payableGl);
// The chain of pooled accounts the group's cost passes through before it is split: Expense, then
// Payable and Paid where the product maps them — each consecutive pair raised once for the whole
// total. The last account in the chain is what Stage 2 debits before crediting each deposit.
final List<Long> chain = new ArrayList<>();
chain.add(expenseGl);
if (hasPayable) {
chain.add(payableGl);
if (hasPaid) {
chain.add(paidGl);
}
}
for (int i = 0; i < chain.size() - 1; i++) {
journalEntry(officeId, currency, chain.get(i), interestDate, postedOn, total, true, reference, null, null, narration);
journalEntry(officeId, currency, chain.get(i + 1), interestDate, postedOn, total, false, reference, null, null, narration);
}
// Stage 2: one debit for the group's total, and under it a credit to each deposit. Every leg
// carries the report's one transaction id, and every credit names its account and interest
// transaction, so who was paid what reads back from the journal alone.
journalEntry(officeId, currency, chain.get(chain.size() - 1), interestDate, postedOn, total, true, reference, null, null,
narration);
for (final PaidLine paid : group) {
journalEntry(officeId, currency, controlGl, interestDate, postedOn, paid.amount(), false, reference, paid.accountId(),
paid.transactionId(), narration);
}
}
private void completeReport(final long reportId, final long productId, final LocalDate interestFor, final BigDecimal approved,
final BigDecimal distributed, final long runId, final int accountCount) {
this.jdbcTemplate.update("""
UPDATE m_fd_interest_posting_run
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
""", approved, distributed, runId);
this.jdbcTemplate.update("""
UPDATE m_fd_interest_posting_report
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
WHERE id = ?
""", runId, reportId);
log.info("FD interest posting: report {} (product {}, for {}) paid {} across {} deposits", reportId, productId, interestFor,
distributed, accountCount);
}
/** One run per report. A retry of the same report reuses its run. */
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate postingDate,
final int accountCount) {
this.jdbcTemplate.update("""
INSERT INTO m_fd_interest_posting_run
(report_id, fd_product_id, office_id, posting_date, account_count, status)
VALUES (?, ?, ?, ?, ?, 'PENDING')
ON CONFLICT (report_id) DO UPDATE SET
account_count = EXCLUDED.account_count, status = 'PENDING',
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
""", reportId, productId, officeId, postingDate, accountCount);
return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_interest_posting_run WHERE report_id = ?", Long.class, reportId);
}
/**
* @param interestDate
* the period the interest is for, which is the transaction's own date
* @param postedOn
* the business date it is being written on, kept as the submitted-on stamp
*/
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate interestDate,
final LocalDate postedOn, final BigDecimal amount, final BigDecimal runningBalance) {
this.jdbcTemplate.update("""
INSERT INTO m_savings_account_transaction
(savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction,
transaction_type_enum, transaction_date, amount, running_balance_derived,
submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc)
VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", accountId, officeId, INTEREST_POSTING, interestDate, amount, runningBalance, postedOn, systemUserId(),
systemUserId());
return this.jdbcTemplate.queryForObject("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ?
ORDER BY id DESC LIMIT 1
""", Long.class, accountId, interestDate, INTEREST_POSTING);
}
/**
* One journal entry leg. Fineract's {@code type_enum} is 2 = DEBIT, 1 = CREDIT — {@code
* JournalEntryType} declares {@code CREDIT(1)} first.
*
* <p>
* Dated the period the interest is for, as the scheduler dates its own legs, with the business date
* kept as the submitted-on stamp.
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate interestDate,
final LocalDate postedOn, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId, final String narration) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
this.jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
savings_transaction_id, reversed, manual_entry, office_running_balance,
organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date,
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
""", officeId, glAccountId, currency, reference, interestDate, interestDate, debit ? 2 : 1, amount, narration,
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(),
postedOn);
}
/**
* The user these rows are attributed to. A day close has a user, but the nightly retry does not and
* the audit columns are NOT NULL, so entries are stamped to the system account either way — as the
* scheduler's own postings always were.
*/
private Long systemUserId() {
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
return ids.isEmpty() ? 1L : ids.get(0);
}
private Long mappedGlAccount(final long productId, final int financialAccountType) {
final Long found = optionalMappedGlAccount(productId, financialAccountType);
if (found == null) {
throw new IllegalStateException("Fixed deposit product " + productId + " has no GL account mapped for financial account type "
+ financialAccountType + ". Set the product's Accounting tab before its interest can be posted.");
}
return found;
}
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
final List<Long> found = this.jdbcTemplate.queryForList("""
SELECT gl_account_id FROM acc_product_mapping
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
LIMIT 1
""", Long.class, productId, financialAccountType);
return found.isEmpty() ? null : found.get(0);
}
/**
* The GL account a product maps under one of the reserved names in {@code other_gl_mappings}, tried
* in order of preference. The column holds an empty string on a product saved with no named
* mappings and casting that to jsonb is an error, so the cast sits inside a subquery that has
* already filtered those rows out.
*/
private Long namedGlAccount(final long productId, final String... accountNames) {
for (final String accountName : accountNames) {
final List<Long> found = this.jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
LIMIT 1
""", Long.class, productId, accountName);
if (!found.isEmpty()) {
return found.get(0);
}
}
return null;
}
}
@@ -0,0 +1,245 @@
/**
* 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.fdinterest.api;
import com.google.gson.Gson;
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.core.MediaType;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
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.savings.fdinterest.FixedDepositInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* The fixed deposit interest posting reports, per branch and product: list, detail, generate, approve,
* the day-close check, and the head office's branch-wise summary.
*
* <p>
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
* day, so there is exactly one way interest reaches a customer's deposit.
*/
@Path("/v1/fixeddepositinterestpostingreports")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class FixedDepositInterestPostingReportApiResource {
private static final String RESOURCE = "FDINTERESTREPORT";
private final FixedDepositInterestPostingReportService reportService;
private final ConfigurationDomainService configurationDomainService;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
@GET
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
@QueryParam("scope") final String scope) {
final AppUser user = this.context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return this.gson
.toJson(this.reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream()
.map(this::plain).toList());
}
/**
* Who is looking: their branch, whether it is the head office (which gets the Other branches tab),
* and whether deposits are on the approval path at all — with the switch off every screen here is
* empty by design, and the UI should say so rather than look broken.
*/
@GET
@Path("/context")
public String userContext() {
final AppUser user = this.context.authenticatedUser();
final Long officeId = user.getOffice().getId();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
result.put("officeName", user.getOffice().getName());
result.put("headOffice", this.reportService.isHeadOffice(officeId));
result.put("approvalEnabled", this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled());
return this.gson.toJson(result);
}
/** Branch and product pairs with deposit interest due by the date and no report yet. */
@GET
@Path("/due-without-report")
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
final AppUser user = this.context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
return this.gson.toJson(this.reportService.dueWithoutReport(date, user, scope));
}
/**
* What stops a branch closing the business day: its own deposit reports due and not yet approved,
* and the products and amounts behind them. The day-close screen reads it to warn before the close
* is attempted.
*/
@GET
@Path("/day-close-check")
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
final AppUser user = this.context.authenticatedUser();
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final Map<String, Object> view = this.reportService.dayCloseInterestView(officeId, businessDate);
@SuppressWarnings("unchecked")
final List<String> blocking = (List<String>) view.get("pending");
final Map<String, Object> result = new LinkedHashMap<>();
result.put("businessDate", businessDate.toString());
result.put("canClose", blocking.isEmpty());
result.put("pendingProducts", blocking);
result.put("due", view.get("due"));
return this.gson.toJson(result);
}
/** What each deposit has earned and not been paid, as if posted today. View only — nothing is saved. */
@GET
@Path("/preview")
public String preview(@QueryParam("officeId") final Long officeId) {
final AppUser user = this.context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return this.gson.toJson(this.reportService.preview(officeId, user).stream().map(this::plain).toList());
}
/** The head office's view: per branch and product, deposits paid and interest, over a date range. */
@GET
@Path("/summary")
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("productId") final Long productId) {
final AppUser user = this.context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
if (!this.reportService.isHeadOffice(user.getOffice().getId())) {
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.summary.head.office.only",
"The branch-wise fixed deposit interest summary is for the head office.");
}
return this.gson
.toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
}
/**
* Deposit interest posted without this report ever seeing it — chiefly a deposit maturing before
* its posting cycle completed, whose last period a different, older path settles directly. Here so
* a branch can see every rupee of deposit interest it has paid, approved or not.
*/
@GET
@Path("/outside-approval")
public String outsideApproval(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) {
final AppUser user = this.context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return this.gson.toJson(
this.reportService.outsideApproval(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream()
.map(this::plain).toList());
}
@GET
@Path("/{reportId}")
public String detail(@PathParam("reportId") final Long reportId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE);
final Map<String, Object> report = plain(this.reportService.detail(reportId));
@SuppressWarnings("unchecked")
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
report.put("lines", lines.stream().map(this::plain).toList());
return this.gson.toJson(report);
}
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
@POST
@Consumes({ MediaType.APPLICATION_JSON })
public String command(@QueryParam("command") final String command, final String body) {
if (!"regenerate".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command);
}
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
final var root = this.fromJsonHelper.parse(body);
final Long productId = this.fromJsonHelper.extractLongNamed("productId", root);
final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root);
final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root);
if (productId == null) {
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.product.required", "Which product?");
}
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
final Long reportId = this.reportService.regenerate(productId, officeId, date, user);
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.GENERATED));
}
/**
* {@code ?command=approve}. A user of the report's branch who did not generate it approves it; see
* the service for the head office's one exception.
*/
@POST
@Path("/{reportId}")
@Consumes({ MediaType.APPLICATION_JSON })
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
if (!"approve".equalsIgnoreCase(command)) {
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command);
}
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
this.reportService.approve(reportId, user);
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.APPROVED));
}
private static LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return LocalDate.parse(raw.trim());
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.invalid.date",
"Dates are expected as yyyy-MM-dd, not " + raw);
}
}
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
private Map<String, Object> plain(final Map<String, Object> row) {
final Map<String, Object> out = new LinkedHashMap<>();
row.forEach((key, value) -> {
if (value instanceof java.sql.Date d) {
out.put(key, d.toLocalDate().toString());
} else if (value instanceof Timestamp t) {
out.put(key, t.toLocalDateTime().toString());
} else {
out.put(key, value);
}
});
return out;
}
}
@@ -0,0 +1,52 @@
/**
* 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.fdinterest.jobs;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class FixedDepositInterestPostingReportConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step fdInterestPostingReportStep(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
return new StepBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
.tasklet(fdInterestPostingReportTasklet, transactionManager).build();
}
@Bean
public Job fdInterestPostingReportJob(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
return new JobBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
.start(fdInterestPostingReportStep(fdInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,57 @@
/**
* 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.fdinterest.jobs;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Noon on the business date: generates the interest posting report for every fixed deposit product
* whose interest falls due, so a branch can review and approve it before it closes the day.
*
* <p>
* Does nothing while deposits are off the approval path — the posting job is paying them directly,
* and a report generated alongside it would be approved against money that has already moved.
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class FixedDepositInterestPostingReportTasklet implements Tasklet {
private final FixedDepositInterestPostingReportService reportService;
private final ConfigurationDomainService configurationDomainService;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception {
if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) {
log.info("FD interest posting report: skipped — fixed deposit interest is posted directly by the posting job");
return RepeatStatus.FINISHED;
}
this.reportService.generateDue(DateUtils.getBusinessLocalDate());
return RepeatStatus.FINISHED;
}
}
@@ -19,8 +19,11 @@
package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
@@ -66,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
DepositAccountData accountData = null;
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
DepositAccountOnClosureType.TRANSFER_TO_GL }
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -77,13 +89,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
// calculate interest before one day of closure date
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
final boolean isPreMatureClosure = true;
// Preview-time only -- the real closure re-reads this same box from its own request when it
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
// maker previewing with the box checked and then submitting without it (or vice versa) gets
// exactly what they last confirmed, not what an earlier preview computed.
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
// the preview a maker sees always matches what the actual closure will compute.
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
element);
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
if (depositAccountType.isFixedDeposit()) {
final FixedDepositAccount fd = (FixedDepositAccount) account;
accountData = FixedDepositAccountData.preClosureDetails(
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
// rather than re-deriving it, to show the maker exactly what applied.
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
accountData = RecurringDepositAccountData.preClosureDetails(
@@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
// normal Close and Payment screens -- would only get it refused by validateClosing.
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
@@ -19,10 +19,13 @@
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingEnumerations;
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
}
}
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonArray()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
}
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonObject()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
}
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
private final String schemaSql;
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
sqlBuilder.append("sp.accounting_type as accountingType, ");
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
}
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
taxGroupData);
final String otherGlMappingsJson = rs.getString("otherGlMappings");
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
return data;
}
}
@@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
return false;
}
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
return Collections.emptyList();
}
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
}
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
return savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
}
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
if (interestTransferWithdrawalIds.isEmpty()) {
return transactions;
}
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
.collect(Collectors.toList());
}
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
orderedNonInterestPostingTransactions.add(transaction);
}
}
@@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
savingsAccountData);
// loop over transactions in reverse
LocalDate endOfBalanceDate = interestPostingUpToDate;
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
@@ -36,6 +36,7 @@ import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.common.AccountingRuleType;
import org.apache.fineract.accounting.glaccount.data.GLAccountData;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
@@ -109,16 +110,24 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
private final SavingsDailyInterestService savingsDailyInterestService;
private final ConfigurationDomainService configurationDomainService;
/** m_savings_account.status_enum for an active account. */
private static final Integer ACTIVE_STATUS = 300;
/** m_savings_product.deposit_type_enum for a fixed deposit. */
private static final Integer DEPOSIT_TYPE_FIXED = 200;
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
final SavingsDailyInterestService savingsDailyInterestService) {
final SavingsDailyInterestService savingsDailyInterestService,
final ConfigurationDomainService configurationDomainService) {
this.context = context;
this.jdbcTemplate = jdbcTemplate;
this.sqlGenerator = sqlGenerator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
this.savingsDailyInterestService = savingsDailyInterestService;
this.configurationDomainService = configurationDomainService;
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
this.transactionsMapper = new SavingsAccountTransactionsMapper();
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
@@ -436,8 +445,23 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
final Integer status, final Long maxSavingsId) {
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
// A product on the daily-interest timetable is paid by the pooled posting instead
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
// subquery so a page is never cut short by accounts that are then dropped.
//
// Fixed deposits come out on the same grounds once their interest goes through an approved
// report (FixedDepositInterestPostingReportService): the report's figures are posted when the
// branch closes its day, and this job running over the same deposits would pay them twice.
// Recurring deposits are not part of that gate and still post here either way.
final String fixedDepositFilter = this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()
? "and a.deposit_type_enum <> " + DEPOSIT_TYPE_FIXED + " "
: "";
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
+ "where s.savings_product_id = a.product_id and s.is_active = true) " + fixedDepositFilter
+ "limit ?) b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
sql = sql
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
@@ -455,6 +479,55 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
return savingsAccountDataList;
}
/**
* The fixed deposits of one branch, and optionally one product, whose interest may be due on the
* posting date — the candidates an interest posting report is drawn from.
*
* <p>
* "May be" is deliberate. A deposit is selected here on the same cheap test the posting job has
* always used, that its interest has not been posted past the day before, and whether a posting
* period has actually ended is then settled by the interest engine, which is the only thing that
* knows a product's compounding and posting periods. A candidate the engine finds nothing for
* simply contributes no line to the report.
*
* <p>
* Unpaged, unlike the posting job: a report covers one branch and one product and is read in full
* before anybody approves it, so there is no page for a deposit to fall off the end of.
*/
@Override
public List<SavingsAccountData> retrieveFixedDepositsDueForInterestPosting(final boolean backdatedTxnsAllowedTill,
final LocalDate postingDate, final Long officeId, final Long productId) {
final StringBuilder candidates = new StringBuilder("select a.id from m_savings_account a ")
.append("left join m_client ic on ic.id = a.client_id ")
.append("left join m_group ig on ig.id = a.group_id ")
.append("where a.status_enum = ? and a.deposit_type_enum = ? ")
.append("and (a.interest_posted_till_date is null or a.interest_posted_till_date <= ?) ");
final List<Object> params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate.minusDays(1)));
if (officeId != null) {
candidates.append("and coalesce(ic.office_id, ig.office_id) = ? ");
params.add(officeId);
}
if (productId != null) {
candidates.append("and a.product_id = ? ");
params.add(productId);
}
String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (" + candidates
+ ") b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
sql = sql
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
}
sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id";
final List<SavingsAccountData> deposits = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR
params.toArray());
for (final SavingsAccountData deposit : deposits) {
this.savingAccountAssembler.assembleSavings(deposit);
}
return deposits;
}
private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor<List<SavingsAccountData>> {
private final String schemaSql;
@@ -430,9 +430,9 @@ public class SavingsConfiguration {
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
// @Lazy because the accrual service depends on beans from this same package: resolving
// both eagerly closes a cycle and the context fails to start.
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
@Lazy SavingsDailyInterestService savingsDailyInterestService, ConfigurationDomainService configurationDomainService) {
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService, configurationDomainService);
}
@Bean
@@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/sponsorbanks")
@Component
@@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
public class SponsorBankApiResource {
private final SponsorBankRepository sponsorBankRepository;
private final PlatformSecurityContext context;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
@@ -125,78 +121,19 @@ public class SponsorBankApiResource {
return names;
}
/**
* Rejects a payable account that cannot serve as one.
*
* A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be
* a liability account, and it has to accept postings. Catching that here means an administrator
* is told while choosing the account, rather than a clerk discovering it when a draft fails.
*/
private void validateDdPayableGlAccount(final Long glAccountId) {
if (glAccountId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
glAccountId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
}
final Map<String, Object> gl = rows.get(0);
final String label = gl.get("gl_code") + " · " + gl.get("name");
// 2 is LIABILITY in Fineract's GLAccountType enum.
if (((Number) gl.get("classification_enum")).intValue() != 2) {
throw new IllegalArgumentException(
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
+ label + " is not a liability account.");
}
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
if (((Number) gl.get("account_usage")).intValue() == 2) {
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
+ label + ".");
}
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
final Object disabled = gl.get("disabled");
final boolean isDisabled = disabled instanceof Boolean b ? b
: disabled instanceof Number n && n.intValue() != 0;
if (isDisabled) {
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
}
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.")
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
// CreateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
// still comes back as the same 400 it always has, rather than an unmapped 500.
return safely(() -> {
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Sponsor Bank Name is required.");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
final AppUser currentUser = this.context.authenticatedUser();
// Set field by field rather than through the all-args constructor: that constructor is
// positional, so adding a column silently shifts every argument after it, and the shift
// type-checks whenever two neighbours share a type.
final SponsorBank sponsorBank = new SponsorBank();
sponsorBank.setName(request.getName().trim());
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
sponsorBank.setCreatedBy(currentUser);
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank()
.withJson(this.gson.toJson(request)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@@ -214,27 +151,14 @@ public class SponsorBankApiResource {
@Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.")
public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId,
final SponsorBankRequest request) {
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
// UpdateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
// still comes back as the same 400 it always has, rather than an unmapped 500.
return safely(() -> {
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
if (request.getName() != null && !request.getName().isBlank()) {
sponsorBank.setName(request.getName().trim());
}
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId)
.withJson(this.gson.toJson(request)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@@ -41,6 +41,9 @@ import java.util.Collection;
import java.util.List;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData;
@@ -76,6 +79,7 @@ public class SponsorDDApiResource {
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final PlatformSecurityContext context;
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final Gson gson = new GsonBuilder().serializeNulls().create();
@@ -539,16 +543,28 @@ public class SponsorDDApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.")
public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) {
return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping(
request.getSponsorBankId(),
request.getDdPayableGlAccountId(),
request.getChequeClearingGlAccountId(),
request.getCashGlAccountId(),
request.getChargesIncomeGlAccountId(),
request.getCashSuspenseGlAccountId(),
request.getGstPercentage(),
request.getGstPayableGlAccountId(),
request.getDdLimitAmount()));
// Routed through the generic command-source pipeline (CommandWrapperBuilder ->
// logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains
// Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping)
// is unchanged and now called from that handler instead of directly from here. The JSON is
// built by hand (not Gson-serializing `request` directly) purely to include a "locale" key -
// JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field.
return safely(() -> {
final JsonObject json = new JsonObject();
json.addProperty("sponsorBankId", request.getSponsorBankId());
json.addProperty("ddPayableGlAccountId", request.getDdPayableGlAccountId());
json.addProperty("chequeClearingGlAccountId", request.getChequeClearingGlAccountId());
json.addProperty("cashGlAccountId", request.getCashGlAccountId());
json.addProperty("chargesIncomeGlAccountId", request.getChargesIncomeGlAccountId());
json.addProperty("cashSuspenseGlAccountId", request.getCashSuspenseGlAccountId());
json.addProperty("gstPercentage", request.getGstPercentage());
json.addProperty("gstPayableGlAccountId", request.getGstPayableGlAccountId());
json.addProperty("ddLimitAmount", request.getDdLimitAmount());
json.addProperty("locale", "en");
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorDdGlMapping()
.withJson(this.gson.toJson(json)).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
});
}
@POST
@@ -0,0 +1,62 @@
/**
* 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.sponsordd.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SPONSORBANK", action = "CREATE")
public class CreateSponsorBankCommandHandler implements NewCommandSourceHandler {
private final SponsorBankWritePlatformService sponsorBankWritePlatformService;
@Autowired
public CreateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) {
this.sponsorBankWritePlatformService = sponsorBankWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SponsorBankRequest request = new SponsorBankRequest();
request.setName(command.stringValueOfParameterNamed("name"));
request.setDescription(command.stringValueOfParameterNamed("description"));
request.setIfscCode(command.stringValueOfParameterNamed("ifscCode"));
request.setBankCode(command.stringValueOfParameterNamed("bankCode"));
request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode"));
request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId"));
final CommandProcessingResult result = this.sponsorBankWritePlatformService.createSponsorBank(request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -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.portfolio.sponsordd.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SPONSORBANK", action = "UPDATE")
public class UpdateSponsorBankCommandHandler implements NewCommandSourceHandler {
private final SponsorBankWritePlatformService sponsorBankWritePlatformService;
@Autowired
public UpdateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) {
this.sponsorBankWritePlatformService = sponsorBankWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SponsorBankRequest request = new SponsorBankRequest();
request.setName(command.stringValueOfParameterNamed("name"));
request.setDescription(command.stringValueOfParameterNamed("description"));
request.setIfscCode(command.stringValueOfParameterNamed("ifscCode"));
request.setBankCode(command.stringValueOfParameterNamed("bankCode"));
request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode"));
request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId"));
final CommandProcessingResult result = this.sponsorBankWritePlatformService.updateSponsorBank(command.entityId(),
request);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -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.sponsordd.handler;
import java.math.BigDecimal;
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.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The underlying write ({@link SponsorDDIssueRequestWritePlatformService#upsertGlMapping}) is a
* genuine upsert - one call creates the single {@code m_sponsor_dd_gl_mapping} row the first time and
* updates it every time after - so there is exactly one command/permission here rather than a
* separate CREATE and UPDATE, matching that reality instead of inventing a split the endpoint has
* never had.
*/
@Service
@CommandType(entity = "SPONSORDDGLMAPPING", action = "UPDATE")
public class UpdateSponsorDdGlMappingCommandHandler implements NewCommandSourceHandler {
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
@Autowired
public UpdateSponsorDdGlMappingCommandHandler(
final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService) {
this.issueRequestWritePlatformService = issueRequestWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final Long sponsorBankId = command.longValueOfParameterNamed("sponsorBankId");
final Long ddPayableGlAccountId = command.longValueOfParameterNamed("ddPayableGlAccountId");
final Long chequeClearingGlAccountId = command.longValueOfParameterNamed("chequeClearingGlAccountId");
final Long cashGlAccountId = command.longValueOfParameterNamed("cashGlAccountId");
final Long chargesIncomeGlAccountId = command.longValueOfParameterNamed("chargesIncomeGlAccountId");
final Long cashSuspenseGlAccountId = command.longValueOfParameterNamed("cashSuspenseGlAccountId");
final BigDecimal gstPercentage = command.bigDecimalValueOfParameterNamed("gstPercentage");
final Long gstPayableGlAccountId = command.longValueOfParameterNamed("gstPayableGlAccountId");
final BigDecimal ddLimitAmount = command.bigDecimalValueOfParameterNamed("ddLimitAmount");
final CommandProcessingResult result = this.issueRequestWritePlatformService.upsertGlMapping(sponsorBankId,
ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId,
cashSuspenseGlAccountId, gstPercentage, gstPayableGlAccountId, ddLimitAmount);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(result.getResourceId()) //
.build();
}
}
@@ -0,0 +1,29 @@
/**
* 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.sponsordd.service;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
public interface SponsorBankWritePlatformService {
CommandProcessingResult createSponsorBank(SponsorBankRequest request);
CommandProcessingResult updateSponsorBank(Long sponsorBankId, SponsorBankRequest request);
}
@@ -0,0 +1,147 @@
/**
* 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.sponsordd.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Moved verbatim out of {@code SponsorBankApiResource.createSponsorBank()} and
* {@code updateSponsorBank()} so both writes can be invoked from a {@code NewCommandSourceHandler}
* for Maker-Checker support. {@link #validateDdPayableGlAccount(Long)} is a copy of the resource's
* own (now-unused) validator, kept here since both CREATE and UPDATE rely on it.
*/
@Service
public class SponsorBankWritePlatformServiceImpl implements SponsorBankWritePlatformService {
private final SponsorBankRepository sponsorBankRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Autowired
public SponsorBankWritePlatformServiceImpl(final SponsorBankRepository sponsorBankRepository,
final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate) {
this.sponsorBankRepository = sponsorBankRepository;
this.context = context;
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
@Override
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Sponsor Bank Name is required.");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
final AppUser currentUser = this.context.authenticatedUser();
// Set field by field rather than through the all-args constructor: that constructor is
// positional, so adding a column silently shifts every argument after it, and the shift
// type-checks whenever two neighbours share a type.
final SponsorBank sponsorBank = new SponsorBank();
sponsorBank.setName(request.getName().trim());
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
sponsorBank.setCreatedBy(currentUser);
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
}
@Transactional
@Override
public CommandProcessingResult updateSponsorBank(final Long sponsorBankId, final SponsorBankRequest request) {
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw new IllegalArgumentException(
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
}
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
if (request.getName() != null && !request.getName().isBlank()) {
sponsorBank.setName(request.getName().trim());
}
sponsorBank.setDescription(request.getDescription());
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
? request.getIfscCode().trim().toUpperCase() : null);
sponsorBank.setBankCode(request.getBankCode());
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
? request.getSwiftCode().trim().toUpperCase() : null);
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
this.sponsorBankRepository.saveAndFlush(sponsorBank);
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
}
/** Copied from {@code SponsorBankApiResource} - see that class for the full rationale. */
private void validateDdPayableGlAccount(final Long glAccountId) {
if (glAccountId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
glAccountId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
}
final Map<String, Object> gl = rows.get(0);
final String label = gl.get("gl_code") + " · " + gl.get("name");
// 2 is LIABILITY in Fineract's GLAccountType enum.
if (((Number) gl.get("classification_enum")).intValue() != 2) {
throw new IllegalArgumentException(
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
+ label + " is not a liability account.");
}
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
if (((Number) gl.get("account_usage")).intValue() == 2) {
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
+ label + ".");
}
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
final Object disabled = gl.get("disabled");
final boolean isDisabled = disabled instanceof Boolean b ? b
: disabled instanceof Number n && n.intValue() != 0;
if (isDisabled) {
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
}
}
}
@@ -18,7 +18,6 @@
*/
package org.apache.fineract.spm.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
@@ -57,6 +60,8 @@ public class SpmApiResource {
private final PlatformSecurityContext securityContext;
private final SpmService spmService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -103,12 +108,10 @@ public class SpmApiResource {
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return getResponse(survey.getId());
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@PUT
@@ -116,12 +119,10 @@ public class SpmApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Transactional
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
this.securityContext.authenticatedUser();
final Survey surveyToUpdate = this.spmService.findById(id);
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return getResponse(survey.getId());
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@@ -131,22 +132,17 @@ public class SpmApiResource {
@Transactional
@Operation(summary = "Deactivate Survey", description = "")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.securityContext.authenticatedUser();
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
final CommandWrapper commandRequest;
if (command != null && command.equalsIgnoreCase("activate")) {
this.spmService.activateSurvey(id);
commandRequest = builder.activateSurvey(id).build();
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
this.spmService.deactivateSurvey(id);
commandRequest = builder.deactivateSurvey(id).build();
} else {
throw new UnrecognizedQueryParamException("command", command);
}
}
private String getResponse(Long id) {
Gson gson = new Gson();
HashMap<String, Object> response = new HashMap<>();
response.put("resourceId", id);
return gson.toJson(response);
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.spm.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "ACTIVATE")
public class ActivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public ActivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.activateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,71 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "CREATE")
public class CreateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey survey = SurveyMapper.map(surveyData, new Survey());
this.spmService.createSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.spm.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.spm.service.SpmService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "DEACTIVATE")
public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
@Autowired
public DeactivateSurveyCommandHandler(final SpmService spmService) {
this.spmService = spmService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
this.spmService.deactivateSurvey(command.entityId());
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(command.entityId()) //
.build();
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.spm.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.spm.data.SurveyData;
import org.apache.fineract.spm.domain.Survey;
import org.apache.fineract.spm.service.SpmService;
import org.apache.fineract.spm.util.SurveyMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "SURVEY", action = "UPDATE")
public class UpdateSurveyCommandHandler implements NewCommandSourceHandler {
private final SpmService spmService;
private final ObjectMapper objectMapper;
@Autowired
public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
this.spmService = spmService;
this.objectMapper = objectMapper;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
final SurveyData surveyData = readSurveyData(command);
final Survey surveyToUpdate = this.spmService.findById(command.entityId());
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
this.spmService.updateSurvey(survey);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(survey.getId()) //
.build();
}
private SurveyData readSurveyData(final JsonCommand command) {
try {
return this.objectMapper.readValue(command.json(), SurveyData.class);
} catch (final JsonProcessingException e) {
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
}
}
}
@@ -97,15 +97,50 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
}
/**
* Permission codes whose approval workflow is a bespoke, unconditional pending-request mechanism
* (m_user_pending_request / m_share_account_pending_edit / m_current_account_pending_edit /
* m_client_pending_edit) that never reads {@code can_maker_checker} - verified by inspecting every
* write-service on this path (UserPendingWritePlatformServiceImpl, ShareAccountEditWritePlatformServiceJpaRepositoryImpl,
* CurrentAccountEditWritePlatformServiceJpaRepositoryImpl, and the client edit equivalent): none of
* them consult ConfigurationDomainService in any way. Toggling this checkbox on the Configure
* Maker-Checker Tasks screen therefore has zero effect on these tasks in either direction, so they
* are excluded here rather than presented as configurable when they are not. CREATE_USER/UPDATE_USER
* remain governed by the separate global "maker-checker" configuration switch (System ->
* Configurations), not by this per-task flag.
*/
private static final String DEAD_MAKER_CHECKER_CODES = "'CREATE_USER','UPDATE_USER',"
+ "'SUBMITEDIT_SHAREACCOUNT','APPROVEEDIT_SHAREACCOUNT','REJECTEDIT_SHAREACCOUNT',"
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
/*
* Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline
* (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a
* different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen
* (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global
* maker-checker on/off switch and other System -> Configurations settings. If either were flipped
* to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin
* could lock themselves out of ever approving further changes to maker-checker configuration
* itself, with no ordinary way to reverse it. Excluding them here only hides them from this
* screen; it does not touch their can_maker_checker value or their normal enforcement.
*/
private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'";
public String makerCheckerablePermissionSchema() {
/*
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
* the READ permissions and the CHECKER permissions
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as
* codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES
* above) to avoid a self-lockout.
*/
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
+ " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")"
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
}
@@ -36,7 +36,13 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.organisation.staff.domain.Staff;
import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
import org.apache.fineract.useradministration.domain.Role;
import org.apache.fineract.useradministration.domain.RoleRepository;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -51,8 +57,13 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
private final JdbcTemplate jdbcTemplate;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final StaffRepositoryWrapper staffRepositoryWrapper;
private final RoleRepository roleRepository;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private static final String UNKNOWN_REFERENCE_FORMAT = "Unknown (ID: %s)";
private static final String SELECT_SQL = """
SELECT r.id AS id,
r.user_id AS userId,
@@ -115,7 +126,96 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
public UserPendingRequestData retrievePendingRequest(final Long id) {
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
final String sql = SELECT_SQL + " WHERE r.id = ?";
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
final UserPendingRequestData data = this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
resolveDisplayNames(data);
return data;
}
/**
* Resolves office/staff/role ids on the pending-user comparison payload to human-readable names, and folds
* the point-in-time original-side name fields (officeName/staffName/roleNames) into their corresponding
* id-keyed rows so the compare dialog shows one row per field instead of a redundant name-only row.
*/
private void resolveDisplayNames(final UserPendingRequestData data) {
final Map<String, Object> originalValues = data.getOriginalValues();
final Map<String, Object> proposedValues = data.getProposedValues();
// CREATE_USER requests have no original snapshot - nothing to fold on that side.
if (originalValues != null && !originalValues.isEmpty()) {
foldNameIntoId(originalValues, "officeName", "officeId");
foldNameIntoId(originalValues, "staffName", "staffId");
if (originalValues.containsKey("roleNames")) {
originalValues.put("roles", sortNames(toStringList(originalValues.get("roleNames"))));
originalValues.remove("roleNames");
}
}
if (proposedValues != null && !proposedValues.isEmpty()) {
if (proposedValues.get("officeId") instanceof Number officeIdNum) {
proposedValues.put("officeId", resolveOfficeName(officeIdNum.longValue()));
}
if (proposedValues.get("staffId") instanceof Number staffIdNum) {
proposedValues.put("staffId", resolveStaffName(staffIdNum.longValue()));
}
if (proposedValues.get("roles") instanceof Collection<?> rolesCollection) {
proposedValues.put("roles", sortNames(resolveRoleNames(toStringList(rolesCollection))));
}
}
}
private static void foldNameIntoId(final Map<String, Object> values, final String nameKey, final String idKey) {
if (values.containsKey(nameKey)) {
values.put(idKey, values.get(nameKey));
values.remove(nameKey);
}
}
private String resolveOfficeName(final Long officeId) {
try {
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
return office.getName();
} catch (final RuntimeException e) {
return String.format(UNKNOWN_REFERENCE_FORMAT, officeId);
}
}
private String resolveStaffName(final Long staffId) {
try {
final Staff staff = this.staffRepositoryWrapper.findOneWithNotFoundDetection(staffId);
return staff.displayName();
} catch (final RuntimeException e) {
return String.format(UNKNOWN_REFERENCE_FORMAT, staffId);
}
}
private List<String> resolveRoleNames(final List<String> roleIds) {
final List<String> names = new ArrayList<>();
for (final String roleIdStr : roleIds) {
try {
final Long roleId = Long.valueOf(roleIdStr);
final Role role = this.roleRepository.findById(roleId).orElse(null);
names.add(role != null ? role.getName() : String.format(UNKNOWN_REFERENCE_FORMAT, roleId));
} catch (final NumberFormatException e) {
names.add(roleIdStr);
}
}
return names;
}
private static List<String> sortNames(final List<String> names) {
final List<String> sorted = new ArrayList<>(names);
sorted.sort(String.CASE_INSENSITIVE_ORDER);
return sorted;
}
private static List<String> toStringList(final Object value) {
final List<String> result = new ArrayList<>();
if (value instanceof Collection<?> collection) {
for (final Object item : collection) {
result.add(String.valueOf(item));
}
}
return result;
}
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRu
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.core.service.PlatformEmailService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
@@ -82,6 +83,7 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
private final PasswordEncoder passwordEncoder;
private final UserDataValidator fromApiJsonDeserializer;
private final FromJsonHelper fromApiJsonHelper;
private final PlatformEmailService emailService;
@Override
@Transactional
@@ -162,8 +164,8 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null);
originalSnapshot.put("staffName", userToUpdate.getStaff() != null ? userToUpdate.getStaff().displayName() : null);
originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires());
originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser());
final Set<String> roleNames = new HashSet<>();
final Set<Long> roleIds = new HashSet<>();
if (userToUpdate.getRoles() != null) {
@@ -293,13 +295,40 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
appUser.updatePasswordResetRequired(true);
}
// Set pre-hashed password
// AppUser.fromJson() already generated a random plaintext password here when the maker ticked
// "send password to email" (it ignores whatever password the maker typed in that case) -- the
// field still holds that plaintext at this point, since nothing has encoded it yet. If the
// maker instead supplied their own password, request.getPasswordHash() carries the bcrypt
// hash submitCreateUser() produced from it; that plaintext was redacted to "[PROTECTED]"
// immediately and is gone for good, so there is nothing to email in that case.
final boolean sendPasswordToEmail = Boolean.TRUE.equals(replay.booleanObjectValueOfParameterNamed("sendPasswordToEmail"));
final String unencodedPassword = appUser.getPassword();
if (request.getPasswordHash() != null) {
// Maker-chosen password: already hashed, used as-is.
appUser.updatePassword(request.getPasswordHash());
} else {
// Auto-generated password (the normal "send password to email" case): this service's own
// generic PasswordEncoder, not the salted PlatformPasswordEncoder the live create-user flow
// uses -- consistent with how the maker-chosen-password branch above and submitCreateUser()
// already hash a password in this file. Encoding it here is not optional: AppUser's
// constructor stores whatever string it is given verbatim (see AppUser(Office, User, ...)),
// so skipping this would persist the plaintext password straight into m_appuser.password.
appUser.updatePassword(this.passwordEncoder.encode(unencodedPassword));
}
this.appUserRepository.saveAndFlush(appUser);
// Mirrors JpaUserDomainService.create(): the welcome email the live create-user flow sends is
// the one thing this parallel, pending-request flow never did -- sendPasswordToEmail was
// accepted and stored in the payload but nothing here ever acted on it, so a user created this
// way never received their password. Only fires for the auto-generated password: there is no
// plaintext to send for a maker-chosen one, per the comment above.
if (sendPasswordToEmail && request.getPasswordHash() == null) {
this.emailService.sendToUserAccount(userOffice.getName(), appUser.getFirstname(), appUser.getEmail(), appUser.getUsername(),
unencodedPassword);
}
// MFA Enrollment
final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod");
if (otpDeliveryMethod != null) {
@@ -321,6 +350,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
if (parsedElement.isJsonObject()) {
// the stored payload keeps a "[PROTECTED]" placeholder for the password rather than
// the real value; the actual new password is applied below via request.getPasswordHash(),
// so these keys must not reach AppUser.update() or it will try to re-encode the placeholder
final JsonObject payloadObject = parsedElement.getAsJsonObject();
payloadObject.remove("password");
payloadObject.remove("repeatPassword");
}
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}

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