From eb875eb65de7272cef2dbdc02233497ee48a6e5c Mon Sep 17 00:00:00 2001 From: Occultus98 Date: Wed, 16 Sep 2026 17:07:24 +0300 Subject: [PATCH 1/6] feat: add Nsimbi user roles foundation --- docs/changes/user-roles-change-log.csv | 7 + .../user-roles-implementation-report.md | 73 ++++++++ .../user-roles-requirements-comparison.md | 176 ++++++++++++++++++ .../service/LoginAttemptEventListener.java | 4 + .../service/PlatformUserDetailsChecker.java | 9 + .../domain/MonetaryAuthorityType.java | 23 +++ .../domain/NsimbiUserMonetaryAuthority.java | 72 +++++++ ...NsimbiUserMonetaryAuthorityRepository.java | 16 ++ .../domain/NsimbiUserSecurityProfile.java | 42 +++++ .../NsimbiUserSecurityProfileRepository.java | 16 ++ .../service/NsimbiClockConfiguration.java | 23 +++ .../NsimbiMonetaryAuthorityPolicyService.java | 31 +++ .../service/NsimbiUserSecurityService.java | 51 +++++ .../tenant/final-changelog-tenant.xml | 1 + .../0242_nsimbi_user_roles_foundation.xml | 62 ++++++ .../LoginAttemptEventListenerTest.java | 7 +- .../PlatformUserDetailsCheckerTest.java | 32 ++++ .../NsimbiUserMonetaryAuthorityTest.java | 38 ++++ ...mbiMonetaryAuthorityPolicyServiceTest.java | 32 ++++ .../NsimbiUserSecurityServiceTest.java | 35 ++++ 20 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 docs/changes/user-roles-change-log.csv create mode 100644 docs/changes/user-roles-implementation-report.md create mode 100644 docs/requirements/user-roles-requirements-comparison.md create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/MonetaryAuthorityType.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthority.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfile.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfileRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiClockConfiguration.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsCheckerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyServiceTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityServiceTest.java diff --git a/docs/changes/user-roles-change-log.csv b/docs/changes/user-roles-change-log.csv new file mode 100644 index 00000000000..7ac7d54fd8f --- /dev/null +++ b/docs/changes/user-roles-change-log.csv @@ -0,0 +1,7 @@ +Change ID,Date,Ticket,Branch,Commit,Area,Change Type,File Path,Class or Method,Previous Behaviour,New Behaviour,Reason,Database Impact,API Impact,Security Impact,Tests Added,Test Result,Developer Notes +UR-001,2026-09-15,Not provided,feat/user-role-administration,Not committed,User lifecycle,Foundation,"fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfile.java",NsimbiUserSecurityProfile,"No Nsimbi-specific suspension or successful-login record","Additive per-operator security profile stores suspension and successful last login","Keep operator lifecycle separate from Staff and client records","New nsimbi_user_security_profile table","No public API completed in this logical unit","Suspension is checked at authentication; failed authentication does not update last login","Not yet","Not run","Profile is created lazily so existing users keep their prior login behaviour until used." +UR-002,2026-09-15,Not provided,feat/user-role-administration,Not committed,Monetary authority,Foundation,"fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyService.java",allows,"No Nsimbi monetary authority model","Defines ten authority types and an inclusive decimal/currency policy contract","Establish reusable future enforcement boundary without changing transactions","New nsimbi_user_monetary_authority table","No public API completed in this logical unit","No transaction handler is enrolled, avoiding unexpected lockout","Not yet","Not run","Zero/unset is represented as unconfigured and never treated as unlimited." +UR-003,2026-09-15,Not provided,feat/user-role-administration,Not committed,Roles and branches,Migration,"fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml",Liquibase changesets,"No extension persistence","Adds branch-assignment and role-operating-hours tables plus required permission seeds","Create forward-compatible Nsimbi extension storage","Four additive tables and five permissions","No public API completed in this logical unit","Permissions are role-granted through existing Fineract RBAC","Not yet","Not run","Operating-hours schema records weekday windows; enforcement and holiday integration remain to be added." +UR-004,2026-09-15,Not provided,feat/user-role-administration,Not committed,Code quality,Correction,"fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles","All new Java sources","New Java files used abbreviated licence comments and unvalidated monetary construction","Applies full Apache licence headers and validates user/type, uppercase ISO currency and inclusive monetary range ordering","Meet repository licensing and defensive-validation conventions","None","None","Invalid authority configurations fail before persistence","NsimbiUserMonetaryAuthorityTest","Passed: 2 tests; zero failures, errors, or skips; Gradle exit code 0 and BUILD SUCCESSFUL","Focused verification used existing compiled outputs where Gradle reported tasks up-to-date." +UR-005,2026-09-15,Not provided,feat/user-role-administration,Not committed,Authentication and policy,Tests,"fineract-provider/src/test/java/org/apache/fineract","Focused unit tests","No tests covered the extension checkpoint","Adds suspension-checker, login-event delegation, fixed-clock last-login, inclusive authority and missing-authority policy tests","Establish focused regression coverage before expanding scope","None","None","Covers denial paths and success-event tracking boundary","PlatformUserDetailsCheckerTest; NsimbiUserSecurityServiceTest; NsimbiUserMonetaryAuthorityTest; NsimbiMonetaryAuthorityPolicyServiceTest; LoginAttemptEventListenerTest","Passed: 5 focused classes, 11 tests; zero failures, errors, or skips; Gradle exit code 0 and BUILD SUCCESSFUL","Focused verification used existing compiled outputs where Gradle reported tasks up-to-date; live database/Liquibase and Docker-dependent integration behaviour remain unverified." +UR-006,2026-09-16,Not provided,feat/user-role-administration,Not committed,Verification,Environment,"/home/ib-s-muhoza/.gradle","Terminal Gradle verification","Focused execution could disappear before a terminal result","Stopped stale daemons and ran the focused suite with OpenJDK 25.0.4, Gradle 8.14.5, writable Gradle cache, and a command-only 4 GiB Gradle heap","Obtain a reliable focused result without global configuration changes","None","None","None","Five focused test classes","Passed: 11 tests; zero failures, errors, or skips; exit code 0 and BUILD SUCCESSFUL; git diff --check passed","Root cause of prior interruptions was host memory exhaustion from multiple Gradle daemons and VS Code processes, not a code failure. Database/Liquibase integration against a live test database and Docker-dependent integration behaviour remain unverified." diff --git a/docs/changes/user-roles-implementation-report.md b/docs/changes/user-roles-implementation-report.md new file mode 100644 index 00000000000..b040b59feaa --- /dev/null +++ b/docs/changes/user-roles-implementation-report.md @@ -0,0 +1,73 @@ +# Nsimbi SACCO Core Banking System — User and Roles implementation report + +## Status + +This is an in-progress Phase 1 foundation on `feat/user-role-administration`, based on local `develop` commit `9493c8b71f439db26dfa0006547ab1e0992ced4b`. No commit has been made. The supplied requirements comparison is now stored verbatim at `docs/requirements/user-roles-requirements-comparison.md`. + +## What is in this logical unit + +Fineract already has an application-user record (`m_appuser`), multiple roles (`m_role`), role permissions (`m_permission`), an optional Staff link, a primary Office, command auditing, password reset, login retry locking, notifications, tellers and cashiers. The Nsimbi work intentionally builds alongside these records instead of confusing an operator with a SACCO client/member. + +The migration creates additive Nsimbi tables: `nsimbi_user_security_profile`, `nsimbi_user_office_assignment`, `nsimbi_role_operating_hours`, and `nsimbi_user_monetary_authority`. It adds five Fineract RBAC permission codes: `SUSPEND_USER`, `REACTIVATE_USER`, `MANAGE_USER_BRANCH_ASSIGNMENTS`, `MANAGE_ROLE_OPERATING_HOURS`, and `MANAGE_USER_MONETARY_AUTHORITY`. + +`NsimbiUserSecurityProfile` records administrative suspension and a successful `lastLoginAt`. The existing `LoginAttemptEventListener` records the timestamp only from Spring Security's success event. `PlatformUserDetailsChecker` rejects a suspended operator before authentication completes. A failed attempt therefore cannot update the timestamp. + +`NsimbiMonetaryAuthorityPolicyService` provides a future transaction-handler boundary. It uses `BigDecimal`, explicit uppercase ISO currency, and inclusive comparisons. Its entity constructor rejects a minimum greater than a maximum. It returns false for an absent authority; this is deliberately not yet wired into banking transactions, so existing users are not locked out during migration. + +```mermaid +flowchart LR + A[Operator login] --> B[Fineract authentication] + B --> C{Nsimbi suspended?} + C -- yes --> D[Reject authentication] + C -- no --> E[Authentication success event] + E --> F[Store lastLoginAt] + G[Future transaction handler] --> H[Nsimbi monetary policy] + H --> I[Configured inclusive range decision] +``` + +## Deferred decisions and limitations + +- Public management APIs, command handlers, authenticated-session invalidation, DTO extensions, role-hours validation/enforcement, branch-assignment validation/enforcement, and transaction-handler enforcement remain deferred. The tables are persisted only; they are not presented as complete features. +- Role disablement currently cannot be applied while the role is assigned in core Fineract. That conflicts with the agreed rule and needs a targeted core change with regression coverage. +- The teller model is office-scoped and references debit/credit GL accounts; a cashier is a Staff assignment valid for a period. Please decide whether the requirement's “Till” means a Fineract Teller, a Cashier assignment, a GL account, or a new SACCO till concept before behavioural enforcement is designed. +- Notifications remain Fineract in-application notifications; product-specific License and Msacco event meanings are not invented here. + +## Requirements traceability + +| Requirement | Existing Fineract Capability | Nsimbi Extension | Implementation Status | Files | Tests | Deferred Reason | +| --- | --- | --- | --- | --- | --- | --- | +| Separate operator identity, optional Staff and primary Office | `m_appuser`, optional Staff, required Office | No separate contact record | Reused / Deferred | AppUser core model; Nsimbi tables | Existing Fineract tests | Phone/contact decision deferred | +| Suspension/reactivation and readable status | Account enabled/locked plus login retry lock | Profile has `suspended`; authentication checker rejects it | Persisted and authentication-enforced; not API-authorized | Security profile, checker, listener | Focused tests passed | Commands/APIs and existing-session invalidation deferred | +| Successful last login only | Authentication success/failure events | `lastLoginAt` updated only by success listener | Enforced at event boundary | LoginAttemptEventListener, security service | Focused tests passed | Persistence integration test deferred | +| Multiple roles and disabled-role assignment behaviour | Many-to-many roles; core rejects disabling assigned role | None | Reused / Deferred | Fineract Role service | None added | Requires approval for smallest compatible core change | +| Additional branch assignment | Primary Office only | Assignment table | Persisted only | 0242 migration | None | Validation, policy and API deferred | +| Operating hours in Africa/Kampala | None | Clock bean and hours table | Persisted / timezone configured only | Clock config, 0242 migration | None | Validator, most-restrictive-role policy and boundaries deferred | +| Ten monetary authority pairs | None | Ten enum values, authority table and inclusive policy | Persisted, validated in constructor, policy callable; not transaction-enforced | Monetary authority classes, 0242 migration | Focused tests passed | Management API and rollout activation deferred | +| Notifications and preferences | In-application notifications | None | Deferred | Existing notification module | None | Event meanings and scope rules need product decisions | +| Till and Chart Accounts | Teller has office and debit/credit GL accounts; Cashier links Staff to Teller | None | Deferred | Teller/cashier services | None | Teller, Cashier, GL and SACCO till are distinct concepts | + +## Verification + +`git diff --check` completed with no output and `xmllint --noout fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml` completed successfully. Focused tests cover administrative suspension, successful-login tracking with a fixed clock, failure-event non-tracking, inclusive authority boundaries, a missing authority, and an invalid range. + +The Gradle build declares a Java 25 toolchain. Terminal verification used OpenJDK 25.0.4 (Java and Javac) with Gradle 8.14.5 and a writable `/home/ib-s-muhoza/.gradle` cache. The focused commands used a command-only 4 GiB Gradle heap; no global configuration was changed. + +The focused suite was invoked as follows: + +```bash +./gradlew :fineract-provider:test \ + --tests org.apache.fineract.infrastructure.security.service.LoginAttemptEventListenerTest \ + --tests org.apache.fineract.infrastructure.security.service.PlatformUserDetailsCheckerTest \ + --tests org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthorityTest \ + --tests org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyServiceTest \ + --tests org.apache.fineract.nsimbi.userroles.service.NsimbiUserSecurityServiceTest \ + --no-daemon --console=plain --stacktrace --info +``` + +The focused suite completed with exit code `0` and `BUILD SUCCESSFUL`: all five focused test classes passed, executing 11 tests with zero failures, errors, or skips. Gradle reported the production and test compilation tasks as up-to-date, so this verification used existing compiled outputs where those tasks were not recompiled. + +The earlier build interruptions were caused by host memory exhaustion: multiple Gradle daemons and VS Code processes consumed excessive RAM. This was an environment failure, not a code failure. Database/Liquibase integration against a live test database and Docker-dependent integration behaviour remain unverified. All deferred module scope listed above remains deferred. + +## Licensing + +No Apache licence, NOTICE, copyright, or attribution files were removed or altered. New Java files must receive the repository's full Apache header before this work is ready for review. diff --git a/docs/requirements/user-roles-requirements-comparison.md b/docs/requirements/user-roles-requirements-comparison.md new file mode 100644 index 00000000000..d6ee06c1871 --- /dev/null +++ b/docs/requirements/user-roles-requirements-comparison.md @@ -0,0 +1,176 @@ +Continue the existing User and Roles Phase 1 implementation on branch `feat/user-role-administration`. + +Do not create another branch. Do not commit, push, merge or rebase. + +The current work is an acceptable intermediate checkpoint, but it is not ready for review or commitment. + +First perform these checks: + +1. Show `git status --short`. +2. Show `git diff --stat`. +3. List all untracked files. +4. Confirm the exact paths of: + + * the implementation report; + * the CSV change log; + * the database migration; + * every new Java file. +5. Check whether the branch was created from the intended local `develop` commit. +6. Fetch remote references and report whether local `develop` is behind, ahead of or diverged from `origin/develop`. +7. Do not change the branch base without my approval. + +Requirements document: + +I will provide the previously generated requirements comparison separately. + +Save its exact content as: + +`docs/requirements/user-roles-requirements-comparison.md` + +Do not reconstruct or summarize missing requirements from memory. Once supplied, compare the implementation against every listed User and Roles field and add a traceability table to the implementation report with these columns: + +`Requirement | Existing Fineract Capability | Nsimbi Extension | Implementation Status | Files | Tests | Deferred Reason` + +Immediate correction: + +Add the repository-required full Apache licence header to every newly created Java source file. Do not alter existing licence, NOTICE, attribution or copyright files. + +Continue Phase 1 with the following priorities: + +1. Complete persistence mappings and database migration validation. +2. Complete validation for: + + * administrative suspension/reactivation; + * additional branch assignments; + * role operating hours; + * monetary authority ranges; + * ISO currency codes; + * minimum amount not exceeding maximum amount; + * duplicate records and assignments. +3. Complete request and response DTOs. +4. Add management service and command-handler operations. +5. Add properly authorized REST API endpoints. +6. Ensure every modifying operation uses Fineract’s command/audit conventions. +7. Add unit and integration tests. +8. Update the CSV and Markdown implementation report after each logical change. + +Suspension requirements: + +* A suspended user must be rejected during authentication. +* Failed authentication must not update `lastLoginAt`. +* Successful authentication must update `lastLoginAt`. +* Suspension and reactivation must require their respective permissions. +* Suspension must not delete roles, office assignments or monetary policies. +* Determine how authentication sessions or tokens work in this Fineract revision. +* Propose the safest compatible method for invalidating existing access after suspension. +* If immediate session invalidation cannot be completed safely in this logical unit, document the exact technical limitation and ensure subsequent authenticated requests still reject the suspended user where possible. +* Add tests covering all these cases. + +Branch-assignment requirements: + +* Primary office remains the existing required Fineract office. +* Additional offices are explicit assignments. +* Reject duplicate additional-office assignments. +* Decide and document whether assigning the primary office again as an additional office is rejected or ignored. +* Check tenant ownership and office existence. +* Require `MANAGE_USER_BRANCH_ASSIGNMENTS` for management operations. +* Do not yet modify every banking endpoint. +* Provide a reusable authorization/policy service for future endpoints. +* Test assigned and unassigned-office decisions. + +Role operating-hours requirements: + +* Use `Africa/Kampala` as the initial policy timezone. +* Do not hard-code the server’s system timezone. +* Validate opening and closing times. +* Define behaviour for overnight time ranges instead of accidentally accepting them. +* Make clock/time access injectable for deterministic tests. +* Require `MANAGE_ROLE_OPERATING_HOURS`. +* Add boundary tests for exactly opening time, exactly closing time, before opening and after closing. +* Do not yet terminate existing sessions at closing time. + +Monetary-authority requirements: + +* Keep all ten required operation types. +* Use `BigDecimal`. +* Store an explicit ISO currency. +* Comparisons are inclusive. +* Reject a minimum greater than the maximum. +* An absent or unset authority must not silently mean unlimited. +* Existing users must not be locked out merely because the migration has run. +* Keep actual transaction-handler enforcement deferred until we approve a backward-compatible rollout. +* Add policy-service tests for: + + * exact minimum; + * exact maximum; + * below minimum; + * above maximum; + * inside range; + * missing authority; + * wrong currency; + * invalid configuration. + +Role disablement: + +Do not change core Fineract’s assigned-role disablement guard yet. + +Instead: + +1. Analyze its security and compatibility consequences. +2. Propose the smallest safe change. +3. Identify required regression tests. +4. Record it as a deferred decision requiring approval. + +Till and Chart Accounts: + +Keep these requirements in the traceability matrix but do not implement them yet. Do not assume Teller, Cashier, Till and GL Account are equivalent. + +Testing environment: + +The previous run found Java 21, while the repository instructions require Java 25. + +Before installing or modifying system Java: + +1. Inspect Gradle toolchain configuration. +2. Inspect repository scripts, containers, devcontainers and CI workflows for the intended Java 25 setup. +3. Check whether a compatible Java 25 installation already exists. +4. Prefer the repository-supported environment. +5. Use a writable project-specific Gradle cache if required. +6. Do not alter global Java or Gradle configuration without approval. + +Run the narrowest relevant tests first. If Java 25 remains unavailable, still inspect and write the tests, but do not claim they pass. + +Documentation: + +Continue updating: + +* `docs/changes/user-roles-change-log.csv` +* `docs/changes/user-roles-implementation-report.md` + +The report must remain understandable to someone learning Java. For each logical feature, explain the path from API request through validation, command/service, entity/repository and database. + +Do not claim a feature is complete merely because its data can be stored. Distinguish: + +* persisted; +* validated; +* exposed through API; +* authorized; +* enforced; +* tested. + +At the end of this continuation, report: + +1. completed functionality; +2. incomplete functionality; +3. changed and untracked files; +4. migrations; +5. API endpoints; +6. permission checks; +7. exact tests executed and results; +8. Java/Gradle environment status; +9. requirements traceability status; +10. risks and approval decisions; +11. suggested next logical unit; +12. confirmation that the CSV and Markdown reports are current. + +Do not commit until I explicitly approve it. diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java index 8fef98d16d0..61d9eb122b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java @@ -22,6 +22,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.apache.fineract.nsimbi.userroles.service.NsimbiUserSecurityService; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.context.event.EventListener; @@ -44,6 +45,7 @@ public class LoginAttemptEventListener { private final ConfigurationDomainService configurationDomainService; private final AppUserRepository appUserRepository; private final CacheManager cacheManager; + private final NsimbiUserSecurityService nsimbiUserSecurityService; @Transactional @EventListener @@ -84,6 +86,8 @@ public void onAuthenticationSuccess(final AuthenticationSuccessEvent event) { return; } + this.nsimbiUserSecurityService.recordSuccessfulLogin(user.getId()); + if (user.getFailedLoginAttempts() <= 0) { return; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java index f840da55f74..1d50484ea89 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java @@ -21,6 +21,9 @@ import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsChecker; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.nsimbi.userroles.service.NsimbiUserSecurityService; +import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; /** @@ -28,10 +31,16 @@ * SpringSecurityPlatformSecurityContext and AuthenticationApiResource after authentication succeeds. */ @Component +@RequiredArgsConstructor public class PlatformUserDetailsChecker implements UserDetailsChecker { + private final NsimbiUserSecurityService nsimbiUserSecurityService; + @Override public void check(UserDetails userDetails) { + if (userDetails instanceof AppUser appUser && nsimbiUserSecurityService.isSuspended(appUser.getId())) { + throw new org.springframework.security.authentication.DisabledException("User account is administratively suspended"); + } if (!userDetails.isCredentialsNonExpired()) { throw new CredentialsExpiredException("User credentials have expired"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/MonetaryAuthorityType.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/MonetaryAuthorityType.java new file mode 100644 index 00000000000..fa9ec38b439 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/MonetaryAuthorityType.java @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.nsimbi.userroles.domain; + +public enum MonetaryAuthorityType { + LOAN_APPROVAL, DISBURSEMENT, DEPOSITS, WITHDRAWALS, SHARES, FIXED_ASSETS, JOURNAL_VOUCHERS, DIRECT_CREDIT, DIRECT_DEBIT, TRANSFER +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthority.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthority.java new file mode 100644 index 00000000000..74ab6ce7a63 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthority.java @@ -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.nsimbi.userroles.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.Objects; +import lombok.Getter; + +@Entity +@Table(name = "nsimbi_user_monetary_authority") +@Getter +public class NsimbiUserMonetaryAuthority { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "appuser_id", nullable = false) + private Long appUserId; + @Enumerated(EnumType.STRING) + @Column(name = "authority_type", nullable = false) + private MonetaryAuthorityType authorityType; + @Column(name = "currency_code", nullable = false, length = 3) + private String currencyCode; + @Column(name = "minimum_amount") + private BigDecimal minimumAmount; + @Column(name = "maximum_amount") + private BigDecimal maximumAmount; + + protected NsimbiUserMonetaryAuthority() {} + + public NsimbiUserMonetaryAuthority(Long userId, MonetaryAuthorityType type, String currency, BigDecimal minimum, BigDecimal maximum) { + this.appUserId = Objects.requireNonNull(userId, "userId must not be null"); + this.authorityType = Objects.requireNonNull(type, "authorityType must not be null"); + this.currencyCode = validateCurrency(currency); + validateRange(minimum, maximum); + this.minimumAmount = minimum; + this.maximumAmount = maximum; + } + + public boolean isConfigured() { return this.minimumAmount != null || this.maximumAmount != null; } + + public boolean allows(BigDecimal amount) { + return isConfigured() && amount != null && (this.minimumAmount == null || amount.compareTo(this.minimumAmount) >= 0) + && (this.maximumAmount == null || amount.compareTo(this.maximumAmount) <= 0); + } + + private static String validateCurrency(String currency) { + if (currency == null || !currency.matches("[A-Z]{3}")) { + throw new IllegalArgumentException("currency must be a three-letter uppercase ISO 4217 code"); + } + return currency; + } + + private static void validateRange(BigDecimal minimum, BigDecimal maximum) { + if (minimum != null && maximum != null && minimum.compareTo(maximum) > 0) { + throw new IllegalArgumentException("minimumAmount must not be greater than maximumAmount"); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityRepository.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityRepository.java new file mode 100644 index 00000000000..efc712f1d0c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityRepository.java @@ -0,0 +1,16 @@ +/** + * 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.nsimbi.userroles.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NsimbiUserMonetaryAuthorityRepository extends JpaRepository { + Optional findByAppUserIdAndAuthorityTypeAndCurrencyCode(Long appUserId, MonetaryAuthorityType authorityType, String currencyCode); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfile.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfile.java new file mode 100644 index 00000000000..5f8f90f00b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfile.java @@ -0,0 +1,42 @@ +/** + * 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.nsimbi.userroles.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.Getter; + +@Entity +@Table(name = "nsimbi_user_security_profile") +@Getter +public class NsimbiUserSecurityProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "appuser_id", nullable = false, unique = true) + private Long appUserId; + @Column(nullable = false) + private boolean suspended; + private OffsetDateTime suspendedOn; + private Long suspendedByAppuserId; + private OffsetDateTime lastLoginAt; + + protected NsimbiUserSecurityProfile() {} + + public NsimbiUserSecurityProfile(Long appUserId) { this.appUserId = appUserId; } + public void suspend(Long byUserId, OffsetDateTime at) { suspended = true; suspendedByAppuserId = byUserId; suspendedOn = at; } + public void reactivate() { suspended = false; suspendedByAppuserId = null; suspendedOn = null; } + public void recordSuccessfulLogin(OffsetDateTime at) { lastLoginAt = at; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfileRepository.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfileRepository.java new file mode 100644 index 00000000000..aed152c6af5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserSecurityProfileRepository.java @@ -0,0 +1,16 @@ +/** + * 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.nsimbi.userroles.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NsimbiUserSecurityProfileRepository extends JpaRepository { + Optional findByAppUserId(Long appUserId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiClockConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiClockConfiguration.java new file mode 100644 index 00000000000..7a75df8f7be --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiClockConfiguration.java @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache + * License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0. Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package org.apache.fineract.nsimbi.userroles.service; + +import java.time.Clock; +import java.time.ZoneId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NsimbiClockConfiguration { + + @Bean + public Clock nsimbiPolicyClock() { + return Clock.system(ZoneId.of("Africa/Kampala")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyService.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyService.java new file mode 100644 index 00000000000..514934b3074 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyService.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache + * License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0. Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package org.apache.fineract.nsimbi.userroles.service; + +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthorityRepository; +import org.springframework.stereotype.Service; + +/** Reusable policy only. Transaction handlers are deliberately not enrolled in phase 1. */ +@Service +@RequiredArgsConstructor +public class NsimbiMonetaryAuthorityPolicyService { + + private final NsimbiUserMonetaryAuthorityRepository authorities; + + public boolean allows(Long userId, MonetaryAuthorityType type, String currency, BigDecimal amount) { + if (userId == null || type == null || currency == null || amount == null) { + return false; + } + return this.authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(userId, type, currency).map(a -> a.allows(amount)) + .orElse(false); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityService.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityService.java new file mode 100644 index 00000000000..7e3da93dc3c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityService.java @@ -0,0 +1,51 @@ +/** + * 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.nsimbi.userroles.service; + +import java.time.Clock; +import java.time.OffsetDateTime; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserSecurityProfile; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserSecurityProfileRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NsimbiUserSecurityService { + + private final NsimbiUserSecurityProfileRepository profiles; + private final Clock clock; + + public boolean isSuspended(Long userId) { + return userId != null && this.profiles.findByAppUserId(userId).map(NsimbiUserSecurityProfile::isSuspended).orElse(false); + } + + @Transactional + public void recordSuccessfulLogin(Long userId) { + profile(userId).recordSuccessfulLogin(OffsetDateTime.now(this.clock)); + } + + @Transactional + public void suspend(Long userId, Long actorId) { + profile(userId).suspend(actorId, OffsetDateTime.now(this.clock)); + } + + @Transactional + public void reactivate(Long userId) { + profile(userId).reactivate(); + } + + private NsimbiUserSecurityProfile profile(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("userId must not be null"); + } + return this.profiles.findByAppUserId(userId).orElseGet(() -> this.profiles.save(new NsimbiUserSecurityProfile(userId))); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 00a8be46542..fd323d5dc0b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -24,4 +24,5 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml new file mode 100644 index 00000000000..ff66f01de45 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0242_nsimbi_user_roles_foundation.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java index 058d9fa52b3..1fc29536277 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java @@ -32,6 +32,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.nsimbi.userroles.service.NsimbiUserSecurityService; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUserRepository; @@ -63,6 +64,8 @@ class LoginAttemptEventListenerTest { private Cache usersCache; @Mock private Cache usersByUsernameCache; + @Mock + private NsimbiUserSecurityService nsimbiUserSecurityService; private LoginAttemptEventListener listener; @@ -70,7 +73,7 @@ class LoginAttemptEventListenerTest { void setUp() { ThreadLocalContextUtil .setTenant(FineractPlatformTenant.builder().id(1L).tenantIdentifier("default").name("default").timezoneId("UTC").build()); - listener = new LoginAttemptEventListener(configurationDomainService, appUserRepository, cacheManager); + listener = new LoginAttemptEventListener(configurationDomainService, appUserRepository, cacheManager, nsimbiUserSecurityService); } @AfterEach @@ -118,6 +121,7 @@ void shouldResetFailedAttemptsOnSuccessfulLogin() { assertEquals(0, user.getFailedLoginAttempts()); assertTrue(user.isAccountNonLocked()); verify(appUserRepository).saveAndFlush(user); + verify(nsimbiUserSecurityService).recordSuccessfulLogin(user.getId()); } @Test @@ -131,6 +135,7 @@ void shouldNotUpdateWhenLimitDisabled() { verifyNoInteractions(appUserRepository); verifyNoInteractions(cacheManager); + verifyNoInteractions(nsimbiUserSecurityService); } @Test diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsCheckerTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsCheckerTest.java new file mode 100644 index 00000000000..b7bd0f1dba5 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsCheckerTest.java @@ -0,0 +1,32 @@ +/** + * 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.security.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import org.apache.fineract.nsimbi.userroles.service.NsimbiUserSecurityService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.DisabledException; + +class PlatformUserDetailsCheckerTest { + + @Test + void rejectsAdministrativelySuspendedUser() { + NsimbiUserSecurityService securityService = org.mockito.Mockito.mock(NsimbiUserSecurityService.class); + AppUser user = org.mockito.Mockito.mock(AppUser.class); + when(user.getId()).thenReturn(7L); + when(securityService.isSuspended(7L)).thenReturn(true); + + PlatformUserDetailsChecker checker = new PlatformUserDetailsChecker(securityService); + + assertThrows(DisabledException.class, () -> checker.check(user)); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityTest.java b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityTest.java new file mode 100644 index 00000000000..5947d6c4e65 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/domain/NsimbiUserMonetaryAuthorityTest.java @@ -0,0 +1,38 @@ +/** + * 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.nsimbi.userroles.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class NsimbiUserMonetaryAuthorityTest { + + @Test + void allowsInclusiveMinimumAndMaximumOnly() { + NsimbiUserMonetaryAuthority authority = new NsimbiUserMonetaryAuthority(1L, MonetaryAuthorityType.DISBURSEMENT, "UGX", + new BigDecimal("100"), new BigDecimal("200")); + + assertTrue(authority.allows(new BigDecimal("100"))); + assertTrue(authority.allows(new BigDecimal("150"))); + assertTrue(authority.allows(new BigDecimal("200"))); + assertFalse(authority.allows(new BigDecimal("99.99"))); + assertFalse(authority.allows(new BigDecimal("200.01"))); + } + + @Test + void rejectsInvalidRange() { + assertThrows(IllegalArgumentException.class, + () -> new NsimbiUserMonetaryAuthority(1L, MonetaryAuthorityType.DISBURSEMENT, "UGX", new BigDecimal("201"), + new BigDecimal("200"))); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyServiceTest.java new file mode 100644 index 00000000000..b4cefc98547 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiMonetaryAuthorityPolicyServiceTest.java @@ -0,0 +1,32 @@ +/** + * 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.nsimbi.userroles.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.Optional; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthorityRepository; +import org.junit.jupiter.api.Test; + +class NsimbiMonetaryAuthorityPolicyServiceTest { + + @Test + void deniesWhenNoAuthorityExists() { + NsimbiUserMonetaryAuthorityRepository repository = org.mockito.Mockito.mock(NsimbiUserMonetaryAuthorityRepository.class); + when(repository.findByAppUserIdAndAuthorityTypeAndCurrencyCode(1L, MonetaryAuthorityType.TRANSFER, "UGX")) + .thenReturn(Optional.empty()); + + NsimbiMonetaryAuthorityPolicyService service = new NsimbiMonetaryAuthorityPolicyService(repository); + + assertFalse(service.allows(1L, MonetaryAuthorityType.TRANSFER, "UGX", BigDecimal.ONE)); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityServiceTest.java new file mode 100644 index 00000000000..5359f876c11 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/userroles/service/NsimbiUserSecurityServiceTest.java @@ -0,0 +1,35 @@ +/** + * 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.nsimbi.userroles.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserSecurityProfile; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserSecurityProfileRepository; +import org.junit.jupiter.api.Test; + +class NsimbiUserSecurityServiceTest { + + @Test + void recordsLastLoginAtUsingInjectedClock() { + NsimbiUserSecurityProfileRepository repository = org.mockito.Mockito.mock(NsimbiUserSecurityProfileRepository.class); + NsimbiUserSecurityProfile profile = new NsimbiUserSecurityProfile(1L); + when(repository.findByAppUserId(1L)).thenReturn(Optional.of(profile)); + Clock clock = Clock.fixed(Instant.parse("2026-09-15T12:00:00Z"), ZoneOffset.UTC); + + new NsimbiUserSecurityService(repository, clock).recordSuccessfulLogin(1L); + + assertEquals("2026-09-15T12:00Z", profile.getLastLoginAt().toString()); + } +} From f58d716bf1964ac68ecafdef31aec9bb686a7796 Mon Sep 17 00:00:00 2001 From: ssali jamil Date: Wed, 16 Sep 2026 22:49:53 +0300 Subject: [PATCH 2/6] feat: add member application workflow foundation --- .../members/domain/MemberApplication.java | 206 ++++++++ .../domain/MemberApplicationChannel.java | 23 + .../domain/MemberApplicationRepository.java | 29 ++ .../MemberApplicationWorkflowStatus.java | 28 + ...erApplicationStateTransitionException.java | 29 ++ .../tenant/final-changelog-tenant.xml | 1 + ...3_nsimbi_member_application_foundation.xml | 95 ++++ .../members/domain/MemberApplicationTest.java | 484 ++++++++++++++++++ 8 files changed, 895 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplication.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationChannel.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationWorkflowStatus.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/exception/InvalidMemberApplicationStateTransitionException.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0243_nsimbi_member_application_foundation.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationTest.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplication.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplication.java new file mode 100644 index 00000000000..5b4b62f1d3e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplication.java @@ -0,0 +1,206 @@ +/** + * 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.nsimbi.members.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.OffsetDateTime; +import java.util.Objects; +import lombok.Getter; +import org.apache.fineract.nsimbi.members.exception.InvalidMemberApplicationStateTransitionException; + +@Entity +@Table(name = "nsimbi_member_application") +@Getter +public class MemberApplication { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "client_id", nullable = false, unique = true) + private Long clientId; + @Enumerated(EnumType.STRING) + @Column(name = "channel", nullable = false, length = 20) + private MemberApplicationChannel channel; + @Enumerated(EnumType.STRING) + @Column(name = "workflow_status", nullable = false, length = 40) + private MemberApplicationWorkflowStatus workflowStatus; + @Column(name = "maker_appuser_id", nullable = false) + private Long makerAppUserId; + @Column(name = "checker_appuser_id") + private Long checkerAppUserId; + @Column(name = "agency_reference", length = 100) + private String agencyReference; + @Column(name = "submitted_on") + private OffsetDateTime submittedOn; + @Column(name = "review_started_on") + private OffsetDateTime reviewStartedOn; + @Column(name = "decided_on") + private OffsetDateTime decidedOn; + @Column(name = "decision_reason", length = 500) + private String decisionReason; + @Version + @Column(name = "version", nullable = false) + private Long version; + @Column(name = "created_on", nullable = false) + private OffsetDateTime createdOn; + @Column(name = "last_modified_on") + private OffsetDateTime lastModifiedOn; + + protected MemberApplication() {} + + public MemberApplication(Long clientId, MemberApplicationChannel channel, Long makerAppUserId, String agencyReference, + OffsetDateTime createdOn) { + this.clientId = Objects.requireNonNull(clientId, "clientId must not be null"); + this.channel = Objects.requireNonNull(channel, "channel must not be null"); + this.makerAppUserId = Objects.requireNonNull(makerAppUserId, "makerAppUserId must not be null"); + this.createdOn = Objects.requireNonNull(createdOn, "createdOn must not be null"); + if (channel == MemberApplicationChannel.AGENCY && (agencyReference == null || agencyReference.isBlank())) { + throw new IllegalArgumentException("AGENCY requires a nonblank agencyReference"); + } + if (channel == MemberApplicationChannel.DIRECT && agencyReference != null) { + throw new IllegalArgumentException("DIRECT must not have an agencyReference"); + } + if (agencyReference != null && agencyReference.length() > 100) { + throw new IllegalArgumentException("agencyReference must not exceed 100 characters"); + } + this.agencyReference = agencyReference == null ? null : agencyReference.strip(); + this.workflowStatus = MemberApplicationWorkflowStatus.DRAFT; + } + + public void submit(OffsetDateTime at) { + requireState("submit", MemberApplicationWorkflowStatus.DRAFT, MemberApplicationWorkflowStatus.RETURNED_FOR_CORRECTION); + requireTimestamp("submit", at); + submittedOn = at; + checkerAppUserId = null; + reviewStartedOn = null; + decidedOn = null; + decisionReason = null; + transitionTo(MemberApplicationWorkflowStatus.SUBMITTED, at); + } + + public void startReview(Long checkerAppUserId, OffsetDateTime at) { + requireState("start.review", MemberApplicationWorkflowStatus.SUBMITTED); + requireChecker("start.review", checkerAppUserId); + requireTimestamp("start.review", at); + this.checkerAppUserId = checkerAppUserId; + reviewStartedOn = at; + transitionTo(MemberApplicationWorkflowStatus.UNDER_REVIEW, at); + } + + public void returnForCorrection(Long checkerAppUserId, String reason, OffsetDateTime at) { + requireReviewAction("return.for.correction", checkerAppUserId, at); + String normalizedReason = validateReason("return.for.correction", reason); + decisionReason = normalizedReason; + transitionTo(MemberApplicationWorkflowStatus.RETURNED_FOR_CORRECTION, at); + } + + public void approve(Long checkerAppUserId, OffsetDateTime at) { + requireReviewAction("approve", checkerAppUserId, at); + decidedOn = at; + transitionTo(MemberApplicationWorkflowStatus.APPROVED, at); + } + + public void reject(Long checkerAppUserId, String reason, OffsetDateTime at) { + requireReviewAction("reject", checkerAppUserId, at); + String normalizedReason = validateReason("reject", reason); + decisionReason = normalizedReason; + decidedOn = at; + transitionTo(MemberApplicationWorkflowStatus.REJECTED, at); + } + + public void withdraw(OffsetDateTime at) { + if (workflowStatus.isTerminal()) { + throw invalid("withdraw", "invalid.state", "Terminal member applications cannot be withdrawn."); + } + requireTimestamp("withdraw", at); + decidedOn = at; + transitionTo(MemberApplicationWorkflowStatus.WITHDRAWN, at); + } + + private void requireReviewAction(String action, Long checker, OffsetDateTime at) { + requireState(action, MemberApplicationWorkflowStatus.UNDER_REVIEW); + requireAssignedChecker(action, checker); + requireTimestamp(action, at); + } + + private void requireAssignedChecker(String action, Long actingCheckerAppUserId) { + requireChecker(action, actingCheckerAppUserId); + if (!actingCheckerAppUserId.equals(checkerAppUserId)) { + throw invalid(action, "checker.not.assigned", "Only the assigned checker may decide this member application."); + } + } + + private void requireState(String action, MemberApplicationWorkflowStatus... allowed) { + for (MemberApplicationWorkflowStatus status : allowed) { + if (workflowStatus == status) { + return; + } + } + throw invalid(action, "invalid.state", "Action is not allowed in the current member application state."); + } + + private void requireChecker(String action, Long checker) { + if (checker == null) { + throw invalid(action, "checker.required", "A checker is required."); + } + if (makerAppUserId.equals(checker)) { + throw invalid(action, "maker.cannot.be.checker", "The maker cannot check their own member application."); + } + } + + private void requireTimestamp(String action, OffsetDateTime at) { + if (at == null) { + throw invalid(action, "timestamp.required", "A transition timestamp is required."); + } + // Every transition records lastModifiedOn, including correction returns that have no final decidedOn. + // Comparing instants also enforces chronology when callers supply different UTC offsets. + if (at.isBefore(createdOn) || (lastModifiedOn != null && at.isBefore(lastModifiedOn))) { + throw invalid(action, "timestamp.before.previous.transition", + "A transition timestamp must not precede creation or the previous workflow transition."); + } + } + + private String validateReason(String action, String reason) { + if (reason == null || reason.isBlank()) { + throw invalid(action, "reason.required", "A nonblank decision reason is required."); + } + if (reason.length() > 500) { + throw invalid(action, "reason.too.long", "A decision reason must not exceed 500 characters."); + } + return reason.strip(); + } + + private InvalidMemberApplicationStateTransitionException invalid(String action, String postfix, String message) { + return new InvalidMemberApplicationStateTransitionException(action, postfix, message); + } + + private void transitionTo(MemberApplicationWorkflowStatus status, OffsetDateTime at) { + workflowStatus = status; + lastModifiedOn = at; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationChannel.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationChannel.java new file mode 100644 index 00000000000..18a399996c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationChannel.java @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.nsimbi.members.domain; + +public enum MemberApplicationChannel { + DIRECT, AGENCY +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationRepository.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationRepository.java new file mode 100644 index 00000000000..e88aace0cd6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationRepository.java @@ -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.nsimbi.members.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberApplicationRepository extends JpaRepository { + + Optional findByClientId(Long clientId); + + boolean existsByClientId(Long clientId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationWorkflowStatus.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationWorkflowStatus.java new file mode 100644 index 00000000000..c30c074ea1a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationWorkflowStatus.java @@ -0,0 +1,28 @@ +/** + * 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.nsimbi.members.domain; + +public enum MemberApplicationWorkflowStatus { + + DRAFT, SUBMITTED, UNDER_REVIEW, RETURNED_FOR_CORRECTION, APPROVED, REJECTED, WITHDRAWN; + + public boolean isTerminal() { + return this == APPROVED || this == REJECTED || this == WITHDRAWN; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/exception/InvalidMemberApplicationStateTransitionException.java b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/exception/InvalidMemberApplicationStateTransitionException.java new file mode 100644 index 00000000000..0276c9bee68 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/nsimbi/members/exception/InvalidMemberApplicationStateTransitionException.java @@ -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.nsimbi.members.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class InvalidMemberApplicationStateTransitionException extends AbstractPlatformDomainRuleException { + + public InvalidMemberApplicationStateTransitionException(final String action, final String postFix, final String defaultUserMessage, + final Object... defaultUserMessageArgs) { + super("error.msg.nsimbi.member.application." + action + "." + postFix, defaultUserMessage, defaultUserMessageArgs); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index fd323d5dc0b..f64bf796c6b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -25,4 +25,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0243_nsimbi_member_application_foundation.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0243_nsimbi_member_application_foundation.xml new file mode 100644 index 00000000000..44d73e788ab --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0243_nsimbi_member_application_foundation.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationTest.java b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationTest.java new file mode 100644 index 00000000000..a0adac3973c --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/nsimbi/members/domain/MemberApplicationTest.java @@ -0,0 +1,484 @@ +/** + * 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.nsimbi.members.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; +import org.apache.fineract.nsimbi.members.exception.InvalidMemberApplicationStateTransitionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class MemberApplicationTest { + + private static final Long MAKER = 10L; + private static final Long CHECKER = 20L; + private static final OffsetDateTime CREATED = OffsetDateTime.parse("2026-09-16T09:00:00+03:00"); + private static final OffsetDateTime SUBMITTED = CREATED.plusHours(1); + private static final OffsetDateTime REVIEWED = CREATED.plusHours(2); + private static final OffsetDateTime DECIDED = CREATED.plusHours(3); + + @Test + void directApplicationStartsInDraft() { + MemberApplication application = draft(); + assertEquals(1L, application.getClientId()); + assertEquals(MemberApplicationChannel.DIRECT, application.getChannel()); + assertEquals(MAKER, application.getMakerAppUserId()); + assertEquals(MemberApplicationWorkflowStatus.DRAFT, application.getWorkflowStatus()); + assertEquals(CREATED, application.getCreatedOn()); + assertNull(application.getAgencyReference()); + assertNull(application.getCheckerAppUserId()); + assertNull(application.getSubmittedOn()); + assertNull(application.getReviewStartedOn()); + assertNull(application.getDecidedOn()); + assertNull(application.getDecisionReason()); + assertNull(application.getLastModifiedOn()); + } + + @Test + void agencyApplicationRecordsReference() { + MemberApplication application = new MemberApplication(1L, MemberApplicationChannel.AGENCY, MAKER, "AGENCY-001", CREATED); + assertEquals(MemberApplicationChannel.AGENCY, application.getChannel()); + assertEquals("AGENCY-001", application.getAgencyReference()); + assertEquals(MemberApplicationWorkflowStatus.DRAFT, application.getWorkflowStatus()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", "\t\n" }) + void agencyRequiresNonblankReference(String reference) { + assertThrows(IllegalArgumentException.class, + () -> new MemberApplication(1L, MemberApplicationChannel.AGENCY, MAKER, reference, CREATED)); + } + + @ParameterizedTest + @ValueSource(strings = { "AGENCY-001", "", " ", "\t" }) + void directRejectsAnySuppliedReference(String reference) { + assertThrows(IllegalArgumentException.class, + () -> new MemberApplication(1L, MemberApplicationChannel.DIRECT, MAKER, reference, CREATED)); + } + + @Test + void constructorRequiresClientChannelMakerAndCreatedOn() { + assertThrows(NullPointerException.class, () -> new MemberApplication(null, MemberApplicationChannel.DIRECT, MAKER, null, CREATED)); + assertThrows(NullPointerException.class, () -> new MemberApplication(1L, null, MAKER, null, CREATED)); + assertThrows(NullPointerException.class, () -> new MemberApplication(1L, MemberApplicationChannel.DIRECT, null, null, CREATED)); + assertThrows(NullPointerException.class, () -> new MemberApplication(1L, MemberApplicationChannel.DIRECT, MAKER, null, null)); + } + + @Test + void draftCanBeSubmitted() { + MemberApplication application = draft(); + application.submit(SUBMITTED); + assertEquals(MemberApplicationWorkflowStatus.SUBMITTED, application.getWorkflowStatus()); + assertEquals(SUBMITTED, application.getSubmittedOn()); + assertEquals(SUBMITTED, application.getLastModifiedOn()); + assertEquals(CREATED, application.getCreatedOn()); + } + + @Test + void submittedApplicationCanEnterReview() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.SUBMITTED); + application.startReview(CHECKER, REVIEWED); + assertEquals(MemberApplicationWorkflowStatus.UNDER_REVIEW, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertEquals(REVIEWED, application.getReviewStartedOn()); + assertEquals(REVIEWED, application.getLastModifiedOn()); + assertEquals(SUBMITTED, application.getSubmittedOn()); + } + + @Test + void reviewedApplicationCanBeApproved() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + application.approve(CHECKER, DECIDED); + assertEquals(MemberApplicationWorkflowStatus.APPROVED, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertEquals(DECIDED, application.getDecidedOn()); + assertEquals(DECIDED, application.getLastModifiedOn()); + assertEquals(REVIEWED, application.getReviewStartedOn()); + assertNull(application.getDecisionReason()); + } + + @Test + void reviewedApplicationCanBeRejected() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + application.reject(CHECKER, "Not eligible", DECIDED); + assertEquals(MemberApplicationWorkflowStatus.REJECTED, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertEquals("Not eligible", application.getDecisionReason()); + assertEquals(DECIDED, application.getDecidedOn()); + assertEquals(DECIDED, application.getLastModifiedOn()); + } + + @Test + void reviewedApplicationCanBeReturnedForCorrection() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + application.returnForCorrection(CHECKER, "Correct applicant details", DECIDED); + assertEquals(MemberApplicationWorkflowStatus.RETURNED_FOR_CORRECTION, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertEquals("Correct applicant details", application.getDecisionReason()); + assertEquals(DECIDED, application.getLastModifiedOn()); + assertNull(application.getDecidedOn()); + } + + @Test + void resubmissionClearsPreviousReviewAndCorrectionFields() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.RETURNED_FOR_CORRECTION); + OffsetDateTime resubmitted = DECIDED.plusHours(1); + application.submit(resubmitted); + assertEquals(MemberApplicationWorkflowStatus.SUBMITTED, application.getWorkflowStatus()); + assertEquals(resubmitted, application.getSubmittedOn()); + assertEquals(resubmitted, application.getLastModifiedOn()); + assertNull(application.getCheckerAppUserId()); + assertNull(application.getReviewStartedOn()); + assertNull(application.getDecisionReason()); + assertNull(application.getDecidedOn()); + application.startReview(CHECKER, resubmitted.plusHours(1)); + application.approve(CHECKER, resubmitted.plusHours(2)); + assertEquals(MemberApplicationWorkflowStatus.APPROVED, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertNull(application.getDecisionReason()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", "\t\n" }) + void rejectionRequiresReasonWithoutMutatingApplication(String reason) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, "reject.reason.required", () -> application.reject(CHECKER, reason, DECIDED)); + assertReviewUnchanged(application); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", "\t\n" }) + void correctionRequiresReasonWithoutMutatingApplication(String reason) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, "return.for.correction.reason.required", + () -> application.returnForCorrection(CHECKER, reason, DECIDED)); + assertReviewUnchanged(application); + } + + @Test + void makerCannotReviewOwnApplication() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.SUBMITTED); + assertCodeUnchanged(application, "start.review.maker.cannot.be.checker", () -> application.startReview(MAKER, REVIEWED)); + assertEquals(MemberApplicationWorkflowStatus.SUBMITTED, application.getWorkflowStatus()); + assertNull(application.getCheckerAppUserId()); + assertNull(application.getReviewStartedOn()); + assertEquals(SUBMITTED, application.getLastModifiedOn()); + } + + @Test + void makerCannotApproveRejectOrReturnApplication() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, "approve.maker.cannot.be.checker", () -> application.approve(MAKER, DECIDED)); + assertCodeUnchanged(application, "reject.maker.cannot.be.checker", () -> application.reject(MAKER, "Reason", DECIDED)); + assertCodeUnchanged(application, "return.for.correction.maker.cannot.be.checker", + () -> application.returnForCorrection(MAKER, "Reason", DECIDED)); + assertReviewUnchanged(application); + } + + @Test + void checkerIsRequiredForAllReviewActions() { + MemberApplication submitted = atState(MemberApplicationWorkflowStatus.SUBMITTED); + assertCodeUnchanged(submitted, "start.review.checker.required", () -> submitted.startReview(null, REVIEWED)); + MemberApplication reviewed = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(reviewed, "approve.checker.required", () -> reviewed.approve(null, DECIDED)); + assertCodeUnchanged(reviewed, "reject.checker.required", () -> reviewed.reject(null, "Reason", DECIDED)); + assertCodeUnchanged(reviewed, "return.for.correction.checker.required", + () -> reviewed.returnForCorrection(null, "Reason", DECIDED)); + assertReviewUnchanged(reviewed); + } + + @Test + void allTransitionsRequireTimestampsWithoutMutatingApplication() { + MemberApplication draft = draft(); + assertCodeUnchanged(draft, "submit.timestamp.required", () -> draft.submit(null)); + assertCodeUnchanged(draft, "withdraw.timestamp.required", () -> draft.withdraw(null)); + assertEquals(MemberApplicationWorkflowStatus.DRAFT, draft.getWorkflowStatus()); + assertNull(draft.getSubmittedOn()); + assertNull(draft.getDecidedOn()); + assertNull(draft.getLastModifiedOn()); + MemberApplication submitted = atState(MemberApplicationWorkflowStatus.SUBMITTED); + assertCodeUnchanged(submitted, "start.review.timestamp.required", () -> submitted.startReview(CHECKER, null)); + assertNull(submitted.getCheckerAppUserId()); + assertNull(submitted.getReviewStartedOn()); + MemberApplication reviewed = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(reviewed, "approve.timestamp.required", () -> reviewed.approve(CHECKER, null)); + assertCodeUnchanged(reviewed, "reject.timestamp.required", () -> reviewed.reject(CHECKER, "Reason", null)); + assertCodeUnchanged(reviewed, "return.for.correction.timestamp.required", + () -> reviewed.returnForCorrection(CHECKER, "Reason", null)); + assertReviewUnchanged(reviewed); + } + + @ParameterizedTest + @EnumSource(value = MemberApplicationWorkflowStatus.class, names = { "DRAFT", "SUBMITTED", "UNDER_REVIEW", "RETURNED_FOR_CORRECTION" }) + void anyNonterminalApplicationCanBeWithdrawn(MemberApplicationWorkflowStatus status) { + MemberApplication application = atState(status); + assertFalse(status.isTerminal()); + application.withdraw(DECIDED.plusHours(1)); + assertEquals(MemberApplicationWorkflowStatus.WITHDRAWN, application.getWorkflowStatus()); + assertEquals(DECIDED.plusHours(1), application.getDecidedOn()); + assertEquals(DECIDED.plusHours(1), application.getLastModifiedOn()); + } + + @ParameterizedTest + @EnumSource(value = MemberApplicationWorkflowStatus.class, names = { "APPROVED", "REJECTED", "WITHDRAWN" }) + void terminalApplicationsRejectEveryTransition(MemberApplicationWorkflowStatus status) { + MemberApplication application = atState(status); + assertTrue(status.isTerminal()); + assertCodeUnchanged(application, "submit.invalid.state", () -> application.submit(DECIDED)); + assertCodeUnchanged(application, "start.review.invalid.state", () -> application.startReview(CHECKER, DECIDED)); + assertCodeUnchanged(application, "approve.invalid.state", () -> application.approve(CHECKER, DECIDED)); + assertCodeUnchanged(application, "reject.invalid.state", () -> application.reject(CHECKER, "Reason", DECIDED)); + assertCodeUnchanged(application, "return.for.correction.invalid.state", + () -> application.returnForCorrection(CHECKER, "Reason", DECIDED)); + assertCodeUnchanged(application, "withdraw.invalid.state", () -> application.withdraw(DECIDED)); + assertEquals(status, application.getWorkflowStatus()); + assertEquals(DECIDED, application.getDecidedOn()); + assertEquals(DECIDED, application.getLastModifiedOn()); + } + + @ParameterizedTest + @EnumSource(value = MemberApplicationWorkflowStatus.class, names = { "DRAFT", "SUBMITTED", "UNDER_REVIEW", "RETURNED_FOR_CORRECTION" }) + void invalidNonterminalTransitionsUseDomainException(MemberApplicationWorkflowStatus status) { + MemberApplication application = atState(status); + if (status != MemberApplicationWorkflowStatus.UNDER_REVIEW) { + assertCodeUnchanged(application, "approve.invalid.state", () -> application.approve(CHECKER, DECIDED)); + assertCodeUnchanged(application, "reject.invalid.state", () -> application.reject(CHECKER, "Reason", DECIDED)); + assertCodeUnchanged(application, "return.for.correction.invalid.state", + () -> application.returnForCorrection(CHECKER, "Reason", DECIDED)); + } + if (status != MemberApplicationWorkflowStatus.SUBMITTED) { + assertCodeUnchanged(application, "start.review.invalid.state", () -> application.startReview(CHECKER, REVIEWED)); + } + if (status == MemberApplicationWorkflowStatus.SUBMITTED || status == MemberApplicationWorkflowStatus.UNDER_REVIEW) { + assertCodeUnchanged(application, "submit.invalid.state", () -> application.submit(SUBMITTED)); + } + assertEquals(status, application.getWorkflowStatus()); + } + + @ParameterizedTest + @ValueSource(strings = { "approve", "reject", "return.for.correction" }) + void differentCheckerCannotDecideApplication(String action) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, action + ".checker.not.assigned", () -> decide(application, action, 30L, "Reason", DECIDED)); + } + + @Test + void submissionCannotPrecedeCreation() { + MemberApplication application = draft(); + assertCodeUnchanged(application, "submit.timestamp.before.previous.transition", () -> application.submit(CREATED.minusNanos(1))); + } + + @Test + void reviewCannotPrecedeSubmission() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.SUBMITTED); + assertCodeUnchanged(application, "start.review.timestamp.before.previous.transition", + () -> application.startReview(CHECKER, SUBMITTED.minusNanos(1))); + } + + @ParameterizedTest + @ValueSource(strings = { "approve", "reject", "return.for.correction" }) + void decisionCannotPrecedeReview(String action) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, action + ".timestamp.before.previous.transition", + () -> decide(application, action, CHECKER, "Reason", REVIEWED.minusNanos(1))); + } + + @Test + void resubmissionCannotPrecedeCorrectionDecision() { + MemberApplication application = atState(MemberApplicationWorkflowStatus.RETURNED_FOR_CORRECTION); + assertCodeUnchanged(application, "submit.timestamp.before.previous.transition", () -> application.submit(DECIDED.minusNanos(1))); + } + + @ParameterizedTest + @EnumSource(value = MemberApplicationWorkflowStatus.class, names = { "DRAFT", "SUBMITTED", "UNDER_REVIEW", "RETURNED_FOR_CORRECTION" }) + void withdrawalCannotPrecedeLatestWorkflowTimestamp(MemberApplicationWorkflowStatus status) { + MemberApplication application = atState(status); + OffsetDateTime latest = switch (status) { + case DRAFT -> CREATED; + case SUBMITTED -> SUBMITTED; + case UNDER_REVIEW -> REVIEWED; + case RETURNED_FOR_CORRECTION -> DECIDED; + default -> throw new AssertionError("Unexpected state: " + status); + }; + assertCodeUnchanged(application, "withdraw.timestamp.before.previous.transition", () -> application.withdraw(latest.minusNanos(1))); + } + + @ParameterizedTest + @EnumSource(value = MemberApplicationWorkflowStatus.class, names = { "DRAFT", "SUBMITTED", "UNDER_REVIEW", "RETURNED_FOR_CORRECTION" }) + void withdrawalAcceptsLatestWorkflowTimestamp(MemberApplicationWorkflowStatus status) { + MemberApplication application = atState(status); + OffsetDateTime latest = application.getLastModifiedOn() == null ? CREATED : application.getLastModifiedOn(); + application.withdraw(latest); + assertEquals(MemberApplicationWorkflowStatus.WITHDRAWN, application.getWorkflowStatus()); + assertEquals(latest, application.getDecidedOn()); + assertEquals(latest, application.getLastModifiedOn()); + } + + @ParameterizedTest + @ValueSource(strings = { "approve", "reject", "return.for.correction" }) + void transitionsAcceptEqualInstantsWithDifferentOffsets(String action) { + MemberApplication application = draft(); + OffsetDateTime sameInstant = CREATED.withOffsetSameInstant(ZoneOffset.UTC); + application.submit(sameInstant); + application.startReview(CHECKER, CREATED); + decide(application, action, CHECKER, "Reason", sameInstant); + assertEquals(sameInstant, application.getLastModifiedOn()); + if ("return.for.correction".equals(action)) { + application.submit(CREATED); + assertEquals(MemberApplicationWorkflowStatus.SUBMITTED, application.getWorkflowStatus()); + assertEquals(CREATED, application.getSubmittedOn()); + } else { + assertEquals(sameInstant, application.getDecidedOn()); + } + } + + @Test + void chronologyComparesInstantsRatherThanLocalTimes() { + MemberApplication application = draft(); + OffsetDateTime earlierInstant = CREATED.minusMinutes(1).withOffsetSameInstant(ZoneOffset.ofHours(4)); + assertTrue(earlierInstant.toLocalDateTime().isAfter(CREATED.toLocalDateTime())); + assertCodeUnchanged(application, "submit.timestamp.before.previous.transition", () -> application.submit(earlierInstant)); + } + + @Test + void agencyReferenceIsTrimmedBeforeStorage() { + MemberApplication application = new MemberApplication(1L, MemberApplicationChannel.AGENCY, MAKER, " \tAGENCY-001\n ", CREATED); + assertEquals("AGENCY-001", application.getAgencyReference()); + } + + @Test + void agencyReferenceAcceptsExactMaximumLength() { + String reference = "A".repeat(100); + MemberApplication application = new MemberApplication(1L, MemberApplicationChannel.AGENCY, MAKER, reference, CREATED); + assertEquals(reference, application.getAgencyReference()); + } + + @ParameterizedTest + @ValueSource(strings = { "A", " " }) + void agencyReferenceRejectsOversizedInputEvenWithTrimmableWhitespace(String extra) { + assertThrows(IllegalArgumentException.class, + () -> new MemberApplication(1L, MemberApplicationChannel.AGENCY, MAKER, "A".repeat(100) + extra, CREATED)); + } + + @ParameterizedTest + @ValueSource(strings = { "reject", "return.for.correction" }) + void decisionReasonIsTrimmedBeforeStorage(String action) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + decide(application, action, CHECKER, " \tCorrect applicant details\n ", DECIDED); + assertEquals("Correct applicant details", application.getDecisionReason()); + } + + @ParameterizedTest + @ValueSource(strings = { "reject", "return.for.correction" }) + void decisionReasonAcceptsExactMaximumLength(String action) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + String reason = "R".repeat(500); + decide(application, action, CHECKER, reason, DECIDED); + assertEquals(reason, application.getDecisionReason()); + } + + @ParameterizedTest + @ValueSource(strings = { "reject", "return.for.correction" }) + void decisionReasonRejectsOversizedInputWithoutChangingApplication(String action) { + MemberApplication application = atState(MemberApplicationWorkflowStatus.UNDER_REVIEW); + assertCodeUnchanged(application, action + ".reason.too.long", () -> decide(application, action, CHECKER, "R".repeat(501), DECIDED)); + assertCodeUnchanged(application, action + ".reason.too.long", + () -> decide(application, action, CHECKER, "R".repeat(500) + " ", DECIDED)); + } + + private static void decide(MemberApplication application, String action, Long checker, String reason, OffsetDateTime at) { + switch (action) { + case "approve" -> application.approve(checker, at); + case "reject" -> application.reject(checker, reason, at); + case "return.for.correction" -> application.returnForCorrection(checker, reason, at); + default -> throw new AssertionError("Unexpected action: " + action); + } + } + + private static void assertCodeUnchanged(MemberApplication application, String suffix, + org.junit.jupiter.api.function.Executable action) { + List before = snapshot(application); + assertCode(suffix, action); + assertEquals(before, snapshot(application), "A rejected operation must leave every entity field unchanged"); + } + + private static List snapshot(MemberApplication application) { + return Arrays.asList(application.getId(), application.getClientId(), application.getChannel(), application.getWorkflowStatus(), + application.getMakerAppUserId(), application.getCheckerAppUserId(), application.getAgencyReference(), + application.getSubmittedOn(), application.getReviewStartedOn(), application.getDecidedOn(), application.getDecisionReason(), + application.getVersion(), application.getCreatedOn(), application.getLastModifiedOn()); + } + + private static MemberApplication draft() { + return new MemberApplication(1L, MemberApplicationChannel.DIRECT, MAKER, null, CREATED); + } + + private static MemberApplication atState(MemberApplicationWorkflowStatus status) { + MemberApplication application = draft(); + if (status == MemberApplicationWorkflowStatus.DRAFT) { + return application; + } + if (status == MemberApplicationWorkflowStatus.WITHDRAWN) { + application.withdraw(DECIDED); + return application; + } + application.submit(SUBMITTED); + if (status == MemberApplicationWorkflowStatus.SUBMITTED) { + return application; + } + application.startReview(CHECKER, REVIEWED); + switch (status) { + case APPROVED -> application.approve(CHECKER, DECIDED); + case REJECTED -> application.reject(CHECKER, "Not eligible", DECIDED); + case RETURNED_FOR_CORRECTION -> application.returnForCorrection(CHECKER, "Correct details", DECIDED); + default -> { + } + } + return application; + } + + private static void assertCode(String suffix, org.junit.jupiter.api.function.Executable action) { + InvalidMemberApplicationStateTransitionException exception = assertThrows(InvalidMemberApplicationStateTransitionException.class, + action); + assertEquals("error.msg.nsimbi.member.application." + suffix, exception.getGlobalisationMessageCode()); + } + + private static void assertReviewUnchanged(MemberApplication application) { + assertEquals(MemberApplicationWorkflowStatus.UNDER_REVIEW, application.getWorkflowStatus()); + assertEquals(CHECKER, application.getCheckerAppUserId()); + assertEquals(REVIEWED, application.getReviewStartedOn()); + assertEquals(REVIEWED, application.getLastModifiedOn()); + assertNull(application.getDecisionReason()); + assertNull(application.getDecidedOn()); + } +} From e09f7a9019b3d43c7f630d3fcac198262cf187fc Mon Sep 17 00:00:00 2001 From: ssali jamil Date: Wed, 23 Sep 2026 17:30:52 +0300 Subject: [PATCH 3/6] feat(savings): expose validated status filter for account listings --- .../api/SavingsAccountsApiResource.java | 34 ++++- ...SavingsAccountReadPlatformServiceImpl.java | 17 +-- .../resources/static/legacy-docs/apiLive.htm | 9 ++ .../api/SavingsAccountsApiResourceTest.java | 103 ++++++++++++++ .../service/SavingsAccountListingTest.java | 130 ++++++++++++++++++ 5 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountListingTest.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index 69eb16e4c3e..e0a03f6af87 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -55,6 +55,7 @@ import org.apache.fineract.infrastructure.core.api.ApiParameterHelper; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.data.UploadRequest; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; @@ -70,6 +71,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; import org.apache.fineract.portfolio.savings.data.SavingsAccountData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; @@ -122,7 +124,9 @@ public String template(@QueryParam("clientId") @Parameter(description = "clientI @GET @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "List savings applications/accounts", operationId = "retrieveAllSavingsAccounts", description = "Lists savings applications/accounts\n\n" - + "Example Requests:\n" + "\n" + "savingsaccounts\n" + "\n" + "\n" + "savingsaccounts?fields=name") + + "The optional status filter accepts a numeric savings status ID. Omit it to list all visible statuses.\n\n" + + "Example Requests:\n savingsaccounts\n savingsaccounts?status=300\n" + + " savingsaccounts?status=100&offset=0&limit=20\n savingsaccounts?status=300&externalId=example") @AlternativeOperationId("retrieveAll_33") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.GetSavingsAccountsResponse.class))) public String retrieveAll(@Context final UriInfo uriInfo, @@ -131,15 +135,20 @@ public String retrieveAll(@Context final UriInfo uriInfo, @QueryParam("offset") @Parameter(description = "offset") final Integer offset, @QueryParam("limit") @Parameter(description = "limit") final Integer limit, @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, - @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) { + @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder, + @QueryParam("status") @Parameter(description = "Numeric savings status ID: 100 pending approval, 200 approved, 300 active, " + + "303 transfer in progress, 304 transfer on hold, 400 withdrawn, 500 rejected, 600 closed, " + + "700 prematurely closed, 800 matured. Omit for all visible statuses.", schema = @Schema(type = "integer", allowableValues = { + "100", "200", "300", "303", "304", "400", "500", "600", "700", "800" })) final String status) { context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME); + validateStatus(status); sqlValidator.validate(orderBy); sqlValidator.validate(sortOrder); sqlValidator.validate(externalId); final SearchParameters searchParameters = SearchParameters.builder().limit(limit).externalId(externalId).offset(offset) - .orderBy(orderBy).sortOrder(sortOrder).build(); + .orderBy(orderBy).sortOrder(sortOrder).status(status).build(); final Page products = savingsAccountReadPlatformService.retrieveAll(searchParameters); @@ -147,6 +156,25 @@ public String retrieveAll(@Context final UriInfo uriInfo, return toApiJsonSerializer.serialize(settings, products, SavingsApiSetConstants.SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS); } + private void validateStatus(final String status) { + if (status == null) { + return; + } + final DataValidatorBuilder validator = new DataValidatorBuilder().resource("savingsaccount").parameter("status").value(status); + final int statusId; + try { + statusId = Integer.parseInt(status); + } catch (NumberFormatException e) { + validator.failWithCode("must.be.an.integer"); + validator.throwValidationErrors(); + return; + } + if (SavingsAccountStatusType.fromInt(statusId) == SavingsAccountStatusType.INVALID) { + validator.failWithCode("must.be.a.valid.savings.account.status.id"); + validator.throwValidationErrors(); + } + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 674e8442d1c..0cbf7dbb2a5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -26,7 +26,6 @@ import java.sql.SQLException; import java.time.LocalDate; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -172,25 +171,22 @@ public Page retrieveAll(final SearchParameters searchParamet sqlBuilder.append(" join m_office o on o.id = c.office_id"); sqlBuilder.append(" where o.hierarchy like ?"); - final Object[] objectArray = new Object[3]; - objectArray[0] = hierarchySearchString; - int arrayPos = 1; + final List parameters = new ArrayList<>(); + parameters.add(hierarchySearchString); if (searchParameters != null) { if (StringUtils.isNotBlank(searchParameters.getStatus())) { sqlBuilder.append(" and sa.status_enum = ?"); - objectArray[arrayPos] = Integer.parseInt(searchParameters.getStatus()); - arrayPos = arrayPos + 1; + parameters.add(Integer.parseInt(searchParameters.getStatus())); } if (StringUtils.isNotBlank(searchParameters.getExternalId())) { sqlBuilder.append(" and sa.external_id = ?"); - objectArray[arrayPos] = searchParameters.getExternalId(); - arrayPos = arrayPos + 1; + parameters.add(searchParameters.getExternalId()); } if (searchParameters.getOfficeId() != null) { sqlBuilder.append(" and c.office_id = ?"); - objectArray[arrayPos++] = searchParameters.getOfficeId(); + parameters.add(searchParameters.getOfficeId()); } if (searchParameters.hasOrderBy()) { sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); @@ -211,8 +207,7 @@ public Page retrieveAll(final SearchParameters searchParamet } } } - final Object[] finalObjectArray = Arrays.copyOf(objectArray, arrayPos); - return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), finalObjectArray, this.savingAccountMapper); + return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), parameters.toArray(), this.savingAccountMapper); } @Override diff --git a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm index 014777dc54e..57d3037968c 100644 --- a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm +++ b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm @@ -29010,10 +29010,19 @@

Close a GSIM Application

List savings applications/accounts

+

Optional status accepts a numeric savings status ID: 100 (pending approval), + 200 (approved), 300 (active), 303 (transfer in progress), 304 (transfer on hold), + 400 (withdrawn), 500 (rejected), 600 (closed), 700 (prematurely closed), or 800 (matured). + Malformed or unsupported values return a validation error. Omit status to retain the existing listing behavior. + Office-hierarchy visibility still applies. Status combines with externalId and pagination; + totalFilteredRecords counts matching accounts before pagination.

Example Requests:

savingsaccounts


savingsaccounts?fields=name
+
savingsaccounts?status=300
+
savingsaccounts?status=100&offset=0&limit=20
+
savingsaccounts?status=300&externalId=example
GET https://Domain Name/api/v1/savingsaccounts diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceTest.java new file mode 100644 index 00000000000..7955a7a9a4e --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceTest.java @@ -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.savings.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.UriInfo; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.portfolio.savings.SavingsApiConstants; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SavingsAccountsApiResourceTest { + + @Mock + private PlatformSecurityContext context; + @Mock + private AppUser user; + @Mock + private SavingsAccountReadPlatformService savingsAccountReadPlatformService; + @Mock + private SqlValidator sqlValidator; + @Mock + private ApiRequestParameterHelper apiRequestParameterHelper; + @Mock + private DefaultToApiJsonSerializer toApiJsonSerializer; + @Mock + private UriInfo uriInfo; + @InjectMocks + private SavingsAccountsApiResource resource; + + @BeforeEach + void setUp() { + when(context.authenticatedUser()).thenReturn(user); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = { "100", "200", "300", "303", "304", "400", "500", "600", "700", "800" }) + void forwardsCanonicalStatusAndExistingParameters(String status) { + when(uriInfo.getQueryParameters()).thenReturn(new MultivaluedHashMap<>()); + resource.retrieveAll(uriInfo, "external-reference", 2, 10, "sa.id", "ASC", status); + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchParameters.class); + verify(savingsAccountReadPlatformService).retrieveAll(captor.capture()); + SearchParameters parameters = captor.getValue(); + assertThat(parameters.getStatus()).isEqualTo(status); + assertThat(parameters.getExternalId()).isEqualTo("external-reference"); + assertThat(parameters.getOffset()).isEqualTo(2); + assertThat(parameters.getLimit()).isEqualTo(10); + assertThat(parameters.getOrderBy()).isEqualTo("sa.id"); + assertThat(parameters.getSortOrder()).isEqualTo("ASC"); + assertThat(parameters.getOfficeId()).isNull(); + verify(user).validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "active", "savingsAccountStatusType.active", "300.0", "2147483648", "0", "-1", "999" }) + void rejectsMalformedOrUnsupportedStatusBeforeQuery(String status) { + assertThatThrownBy(() -> resource.retrieveAll(uriInfo, null, null, null, null, null, status)) + .isInstanceOfSatisfying(PlatformApiDataValidationException.class, exception -> { + assertThat(exception.getErrors()).hasSize(1); + assertThat(exception.getErrors().get(0).getParameterName()).isEqualTo("status"); + }); + verifyNoInteractions(savingsAccountReadPlatformService); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountListingTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountListingTest.java new file mode 100644 index 00000000000..0cf81d3d2c6 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountListingTest.java @@ -0,0 +1,130 @@ +/** + * 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.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.core.service.database.DatabaseTypeResolver; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@ExtendWith(MockitoExtension.class) +class SavingsAccountListingTest { + + @Mock + private PlatformSecurityContext context; + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private DatabaseTypeResolver databaseTypeResolver; + @Mock + private AppUser user; + @Mock + private Office office; + + @ParameterizedTest + @CsvSource({ ",,", "300,,", "100,,", ",reference,", "300,reference,", "100,reference,7", ",,7", ",reference,7" }) + void bindsOptionalFiltersInOrderAndPreservesHierarchyAndFilteredCount(String status, String externalId, Long officeId) { + SearchParameters parameters = SearchParameters.builder().status(status).externalId(externalId).officeId(officeId).limit(1).offset(1) + .build(); + verifyListing(parameters, status, externalId, officeId); + } + + @Test + void supportsExistingNullSearchParameters() { + verifyListing(null, null, null, null); + } + + private void verifyListing(SearchParameters parameters, String status, String externalId, Long officeId) { + when(context.authenticatedUser()).thenReturn(user); + when(user.getOffice()).thenReturn(office); + when(office.getHierarchy()).thenReturn(".1.2."); + if (parameters != null) { + when(databaseTypeResolver.isPostgreSQL()).thenReturn(true); + } + DatabaseSpecificSQLGenerator generator = new DatabaseSpecificSQLGenerator(databaseTypeResolver, null); + PaginationHelper paginationHelper = new PaginationHelper(generator, databaseTypeResolver); + SavingsAccountReadPlatformServiceImpl service = new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, null, + paginationHelper, null, generator, null, null); + SavingsAccountData item = mock(SavingsAccountData.class); + doReturn(List.of(item)).when(jdbcTemplate).query(anyString(), any(RowMapper.class), any(Object[].class)); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(3); + + Page result = service.retrieveAll(parameters); + + assertThat(result.getPageItems()).containsExactly(item); + assertThat(result.getTotalFilteredRecords()).isEqualTo(3); + ArgumentCaptor rowsSql = ArgumentCaptor.forClass(String.class); + ArgumentCaptor rowsArgs = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate).query(rowsSql.capture(), any(RowMapper.class), rowsArgs.capture()); + ArgumentCaptor countSql = ArgumentCaptor.forClass(String.class); + ArgumentCaptor countArgs = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate).queryForObject(countSql.capture(), eq(Integer.class), countArgs.capture()); + List expected = new ArrayList<>(); + expected.add(".1.2.%"); + assertThat(rowsSql.getValue()).contains("join m_office o on o.id = c.office_id", "where o.hierarchy like ?"); + if (status != null) { + assertThat(rowsSql.getValue()).contains("and sa.status_enum = ?"); + expected.add(Integer.parseInt(status)); + } else { + assertThat(rowsSql.getValue()).doesNotContain("and sa.status_enum = ?"); + } + if (externalId != null) { + assertThat(rowsSql.getValue()).contains("and sa.external_id = ?"); + expected.add(externalId); + } else { + assertThat(rowsSql.getValue()).doesNotContain("and sa.external_id = ?"); + } + if (officeId != null) { + assertThat(rowsSql.getValue()).contains("and c.office_id = ?"); + expected.add(officeId); + } + assertThat(rowsArgs.getValue()).containsExactlyElementsOf(expected); + assertThat(countArgs.getValue()).containsExactlyElementsOf(expected); + assertThat(countSql.getValue()).isEqualTo(generator.countQueryResult(rowsSql.getValue())); + assertThat(countSql.getValue()).doesNotContain("LIMIT", "OFFSET"); + if (parameters != null) { + assertThat(rowsSql.getValue()).contains("LIMIT 1 OFFSET 1"); + } + } +} From dba542f480d063280dbb9773ff42740b14a37690 Mon Sep 17 00:00:00 2001 From: ssali jamil Date: Thu, 24 Sep 2026 22:12:41 +0300 Subject: [PATCH 4/6] fix(savings): reject unsupported annual fee command --- docs/changes/savings-annual-fee-command.md | 179 ++++++++++++ .../api/SavingsAccountsApiResource.java | 10 +- ...AnnualFeeSavingsAccountCommandHandler.java | 10 +- .../resources/static/legacy-docs/apiLive.htm | 12 + .../api/SavingsAnnualFeeCommandTest.java | 121 ++++++++ .../SavingsAnnualFeeMakerCheckerTest.java | 267 ++++++++++++++++++ .../jobs/SavingsAnnualFeeSchedulerTest.java | 72 +++++ 7 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 docs/changes/savings-annual-fee-command.md create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java diff --git a/docs/changes/savings-annual-fee-command.md b/docs/changes/savings-annual-fee-command.md new file mode 100644 index 00000000000..5421eaee7c9 --- /dev/null +++ b/docs/changes/savings-annual-fee-command.md @@ -0,0 +1,179 @@ +# Savings annual-fee command investigation + +Baseline: `92508ab8a4c762256e6937c7813ed15b2b74906a` on `dev`, after fetch and fast-forward-only pull (already up to date). This contains savings status-filter merge `92508ab8a4` and implementation `e09f7a9019`. Branch: `fix/savings-annual-fee-command`. + +## Command contract and current behavior + +The following baseline findings come from source and Git-history analysis. The maker-checker follow-up also reproduces the old null-handler behavior through real command services in unit tests; it is not a live HTTP/database reproduction. + +1. **HTTP response:** `ApplyAnnualFeeSavingsAccountCommandHandler.processCommand` returns null. In ordinary execution with maker-checker disabled, `CommandSourceService.validateMakerChecker` dereferences it with `result.isRollbackTransaction()`. `DefaultExceptionMapper.toResponse` maps the resulting runtime exception to **HTTP 500**, with an `Exception` JSON field. Thus the baseline is not a successful HTTP null response. With maker-checker enabled and an unapproved non-checker-superuser request, validation instead throws `RollbackTransactionNotApprovedException`; `fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/RollbackTransactionNotApprovedExceptionMapper.java#toResponse` returns **HTTP 200** with the awaiting-approval command result (an apparent acceptance, not a financial operation); approved execution still cannot produce a valid successful result. +2. **Apparent success:** no ordinary successful execution response survives the current command pipeline; the maker-checker branch can return HTTP 200 for awaiting approval. The handler itself is nevertheless a null-returning non-operation and has no valid financial result. +3. **Audit:** `SynchronousCommandProcessingService.executeCommandAttempt` saves an initial command; `persistFinalErrorResult` persists the failed response and ERROR status for ordinary non-enclosing-batch execution. Enclosing batch transactions follow their existing rollback/audit rules. +4. **Events and financial effects:** the handler calls no write service and creates no charge, payment, savings transaction, or journal entry. The outer command pipeline can publish a generic **error hook event** through `publishHookErrorEvent`; this is distinct from a savings business/accounting event. It would be incorrect to claim no event whatsoever. + +Exact command-path sources: + +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java`: `handleCommands` (numeric and external IDs), `handleGSIMCommands`. +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java`: `savingsAccountApplyAnnualFees`, action `APPLYANNUALFEE`, entity `SAVINGSACCOUNT`. +- `fineract-core/src/main/java/org/apache/fineract/commands/provider/CommandHandlerProvider.java`: `initializeHandlerRegistry`, `getHandler`; discovers `@CommandType`. +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java`: `processCommand`. +- `fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java`: `logCommandSource`, including permission validation before execution. +- `fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java`: `executeCommandAttempt`, `executeCommandInTransaction`, `persistFinalErrorResult`, `publishHookErrorEvent`. +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java`: `processCommandAndSaveResult`, `validateMakerChecker`. +- `fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/DefaultExceptionMapper.java`: `toResponse`. +- `fineract-provider/src/main/resources/db/changelog/tenant/parts/0002_initial_data.xml`: existing `APPLYANNUALFEE_SAVINGSACCOUNT` and checker permissions. No permission change is needed. + +## Scheduled charges, duplicate protection and accounting + +5. **Scheduler operation:** both jobs select charges already attached to accounts and collect overdue amounts. They do not create/attach annual charges or implement a separate assessment engine. Due dates exist on recurring charge records; payment advances them. + +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/applyannualfeeforsavings/ApplyAnnualFeeForSavingsTasklet.java`: `execute` calls `retrieveChargesWithAnnualFeeDue` then `applyAnnualFee(chargeId, accountId)`. +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/payduesavingscharges/PayDueSavingsChargesTasklet.java`: `execute` calls `retrieveChargesWithDue` then `applyChargeDue(chargeId, accountId)`. +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountChargeReadPlatformServiceImpl.java`: `retrieveChargesWithAnnualFeeDue` selects annual charges due on/before business date on active accounts; `retrieveChargesWithDue` additionally filters active, unpaid, unwaived charges. +- `fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java`: the reusable `applyAnnualFee` signature requires a **charge ID and account ID**, not the old account/date manual contract. +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java`: `applyAnnualFee` collects each prior due period at that due date; `applyChargeDue` collects outstanding amounts using the current business date. Both loops use strictly-before date checks even though selection includes today. This existing boundary is unchanged. Private `payCharge` validates balances, creates/persists the transaction, updates interest and invokes `postJournalEntries`. +- That same write service's `addSavingsAccountCharge`, plus `fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountChargeAssembler.java` (`fromSavingsProduct` and JSON assembly), attach charges outside the scheduler. + +6. **Duplicate protection:** `SavingsAccount.addCharge` and `SavingsAccountChargeAssembler.validateSavingsCharges` reject multiple annual charges. `SavingsAccount.payCharge` rejects annual transactions before the due date or on the latest existing annual-fee transaction date, and validates paid/waived state. `SavingsAccountCharge.pay` advances the recurring due date on full payment and resets recurring balances through `updateNextDueDateForRecurringFees` and `resetPropertiesForRecurringFees`. These are business safeguards, not proof of race-free concurrent collection. +7. **Manual double charging:** the baseline null handler cannot charge at all; the corrected handler cannot either. Reconnecting it blindly to a scheduled operation would require choosing a charge and resolving date, catch-up, authorization and concurrency semantics. No new manual financial operation is justified by the current contract. + +Aggregate and accounting sources: + +- `fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java`: `addCharge`, `isAnnualFeeExists`, `payCharge`, `findLatestAnnualFeeTransactionDueDate`, `handleChargeTransactions`. Annual payment creates `SavingsAccountTransaction.annualFee` and a `SavingsAccountChargePaidBy` link. +- `fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountCharge.java`: `pay`, `updateNextDueDateForRecurringFees`, `calculateNextDueDate`, `resetPropertiesForRecurringFees`. +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java`: `postJournalEntries` derives accounting bridge data and calls `JournalEntryWritePlatformService.createJournalEntriesForSavings`. The manual handler reaches none of this. No savings business-event notification is present in the traced annual-fee payment path itself. + +## History and existing coverage + +8. **Inherited behavior:** the null implementation predates this fork's Savings work and is present in the inherited Mifos/Fineract history. Local `origin/develop` also contains the disabling revision. This conclusion uses repository history; it is not a claim about a newly fetched upstream HEAD. +9. **Earlier working implementation:** `b48bc6269178fe8936808c19fc92fe7613edf550` (2013-07-25, MIFOSX-397) added manual/scheduled annual fees and delegated `(savingsId, annualFeeTransactionDate)`. `0694dde72fd926c365bf643fed729b0045436861` (2013-09-27, “Move withdrawal and annual fee charges to charges workflow”) commented out that call and returned null while moving to charge records. `git log --follow` shows later annotation/package/formatting/injection changes, with no restoration of the manual operation. The old implementation depended on the superseded model and is not a complete current reusable manual contract. +10. **Existing tests:** under `integration-tests/src/test/java/org/apache/fineract/integrationtests/`: + - `SchedulerJobsTestResults.testApplyAnnualFeeForSavingsJobOutcome` checks the scheduled job and next annual due date; its scenario runs before the annual due date, so it is not comprehensive collection coverage. + - `ClientSavingsIntegrationTest.testSavingsAccountCharges`, `testAnnualChargePaymentAfterDueDate` cover attached annual charges/payment behavior. + - `GroupSavingsIntegrationTest.testSavingsAccountCharges` covers annual charges for group savings. + - `ChargesTest` covers creation/update/deletion of annual charge definitions. + - `InstanceModeIntegrationTest` references annual-fee job instance-mode configuration. + +No current `applyAnnualFees` success test or promise was found in OpenAPI annotations or `fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm`. The historical implementation does not establish a present tested success contract. + +## Selected correction + +Keep the route, registration and permission. Throw existing `UnsupportedCommandException("applyAnnualFees", ...)` from the handler. `fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/UnsupportedCommandExceptionMapper.java#toResponse` supplies HTTP **400**, global code `validation.msg.validation.errors.exist` and detail code `error.msg.command.unsupported`; the scheduler explanation is in the detail's `developerMessage`. + +The only executable production change is the handler body. OpenAPI and legacy documentation explain scheduler-controlled annual charges. Scheduler jobs, charge calculation, balances, accounting, schema and permissions are unchanged. Clients relying on the former accidental 500 or maker-checker behavior will now receive an explicit 400 once the handler is reached; authentication, permission, parsing and idempotency checks still run first. Generic failure auditing/hooks remain available. + +## Validation scope + +`SavingsAnnualFeeCommandTest` tests numeric-ID, external-ID and GSIM routes with the real rejecting handler and real exception mapper; asserts status/code, verifies no write-service or success-serializer calls, checks unrelated command dispatch and OpenAPI text. The no-financial-effect assertion is at the write-service boundary, not a live database row-count assertion. `SavingsAnnualFeeSchedulerTest` checks both existing tasklets still delegate charge/account IDs to their existing collection operations. It does not claim full scheduler accounting or concurrency integration coverage. + +Live integration tests require a running configured Fineract service and database. The complete Gradle suite and Swagger generation are intentionally excluded. Excluding `:fineract-provider:resolve` does not hide a generated dependency used by these focused tests, which exercise Java classes/annotations directly. + +## Validation results + +Memory/process checks were performed before the builds. Gradle and test heaps were capped at 768 MiB with one worker/fork. No complete suite or Swagger generation ran. + +Successful formatting command: + +```sh +./gradlew --offline --no-daemon --max-workers=1 --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-annual-fee-tests.gradle \ + :fineract-provider:spotlessJavaApply +``` + +Result: `BUILD SUCCESSFUL in 34s`; both scoped Spotless tasks executed. + +Final test/check command: + +```sh +./gradlew --offline --no-daemon --max-workers=1 --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-annual-fee-tests.gradle \ + :fineract-provider:test \ + --tests '*SavingsAnnualFeeCommandTest' \ + --tests '*SavingsAnnualFeeSchedulerTest' \ + :fineract-provider:spotlessJavaCheck -x :fineract-provider:resolve +``` + +Result: **10 tests, 0 failures**, test execution 11.9s; `BUILD SUCCESSFUL in 1m 35s`. `spotlessJavaCheck` passed. Initial test execution had 3 assertion failures because the test checked `defaultUserMessage` instead of the mapper's `developerMessage`; the assertion was corrected and all tests rerun. An initial IDE-hook formatting attempt skipped all files, and an initial temporary init script failed while configuring buildSrc; neither was counted as successful formatting validation. + +The temporary init script contains: + +```groovy +allprojects { + tasks.withType(Test).configureEach { + maxHeapSize = '768m' + maxParallelForks = 1 + } +} +gradle.projectsEvaluated { + def provider = rootProject.findProject(':fineract-provider') + if (provider == null) { return } + provider.spotless { + java { + target 'src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java', + 'src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java', + 'src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java', + 'src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java', + 'src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java' + } + } +} +``` + +`git diff --check` passed; new files also passed `git diff --no-index --check /dev/null `. The full tracked diff and new files were reviewed. At the original validation, six files were changed/added: handler, API annotations, legacy API page, two focused tests, and this report. The maker-checker follow-up adds a seventh file, the dedicated maker-checker test. No scheduled implementation, savings aggregate/write service, accounting implementation, migration or permission changed. + +Suggested commit/PR title: `: Reject unsupported manual savings annual-fee commands` (replace the placeholder with the assigned issue; contribution instructions require an issue ID). Suggested commit trailer: `Assisted-By: Codex-GPT-6`. + +Suggested PR description: + +> The savings applyAnnualFees handler returned null, causing an HTTP 500 in normal execution or an awaiting-approval HTTP 200 through maker-checker without applying a fee. Reject the command using the existing unsupported-command HTTP 400 response and document scheduler-controlled annual charges. Preserve routes, permissions and scheduled collection/accounting. Add focused coverage for all command routes, stable error codes, no financial write-service calls, unrelated dispatch, documentation and scheduler delegation. Validation: 18 focused tests passed, including maker-checker rejection and existing-command approval; scoped Spotless and diff checks passed. Live integration tests and Swagger generation were not run. + +Nothing was committed, pushed or submitted as a pull request. + +## Final maker-checker verification + +The handler executes **before** maker-checker validation in both modes. The earlier HTTP 200 finding did not mean queuing preceded execution: the old handler had already executed and returned null when the command was marked awaiting approval. + +Exact path for a fresh command: + +1. `SavingsAccountsApiResource.handleCommands` builds `APPLYANNUALFEE_SAVINGSACCOUNT` and calls `PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource`. +2. `logCommandSource` validates permissions/update availability and parses JSON, then calls `SynchronousCommandProcessingService.executeCommand(..., false)`. +3. `executeCommandAttempt` resolves idempotency/authentication and calls `CommandSourceService.saveInitial`. The initial state is `UNDER_PROCESSING`, an audit/idempotency record, **not** `AWAITING_APPROVAL`. +4. `executeCommandInTransaction` selects the handler and calls `CommandSourceService.processCommandAndSaveResult`. +5. That method first calls `handler.processCommand(command)`, and only after a successful return calls `validateMakerChecker`. Only `validateMakerChecker` calls `markAsAwaitingApproval` for a non-checker submission. +6. The annual-fee handler throws before step 5 can validate or queue. `persistFinalErrorResult` saves `ERROR` and HTTP 400. Neither mode creates an awaiting-approval entry. Initial audit persistence and generic error hooks remain intact. + +| Mode | Original null handler | Current rejecting handler | +| --- | --- | --- | +| Maker-checker disabled | Null dereference after execution; HTTP 500, ERROR audit | HTTP 400, ERROR audit; no approval queue | +| Maker-checker enabled, ordinary maker | Null return followed by awaiting-approval transition; HTTP 200 | HTTP 400 before maker-checker validation; no approval queue | +| Approval of an existing annual-fee entry | Null result cannot complete successful execution | HTTP 400; no checker/success result; failed existing entry saved as ERROR | + +Approval path: `PortfolioCommandSourceWritePlatformServiceImpl.approveEntry` validates pending state/checker permission, reconstructs the wrapper/JSON and calls `executeCommand(..., true)`. The same real handler throws before `markAsChecked` or successful-result persistence. The rejection boundary needs no change, and no global maker-checker logic was modified. + +New `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java` uses real `PortfolioCommandSourceWritePlatformServiceImpl`, `SynchronousCommandProcessingService`, `CommandSourceService`, savings handlers and error mappers. Repository saves are mocked and their states snapshotted at call time. Eight cases cover rejection with each setting, approval of an existing queued annual command with each setting, supported activation success without maker-checker, supported activation queuing/approval with maker-checker, and the old null-handler behavior with each setting. Persistence/financial collaborators are mocked: these tests verify control flow and requested persistence states, not database transactions or Spring proxy rollback behavior. + +Maker-checker verification command (temporary init script shown above): + +```sh +./gradlew --offline --no-daemon --max-workers=1 --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-annual-fee-tests.gradle \ + :fineract-provider:test \ + --tests '*SavingsAnnualFeeCommandTest' \ + --tests '*SavingsAnnualFeeSchedulerTest' \ + --tests '*SavingsAnnualFeeMakerCheckerTest' \ + :fineract-provider:spotlessJavaCheck -x :fineract-provider:resolve +``` + +Final result: **18 tests, 0 failures, 0 errors, 0 skipped** (8 command/API, 2 scheduler, 8 maker-checker); test execution 14.9s; `BUILD SUCCESSFUL in 1m 28s`. Scoped `spotlessJavaApply` also passed (35s); `spotlessJavaCheck` and `git diff --check` passed. The first combined run had two fixture failures because the manually seeded historical command omitted its stored URL; the fixture was corrected to use the real annual-fee wrapper URL, and all 18 tests were rerun successfully. + +This follow-up changes only this report and the new maker-checker test; the production rejection boundary remains unchanged. Full uncommitted file list: + +- `docs/changes/savings-annual-fee-command.md` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java` +- `fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java` + +The complete diff and new files were reviewed; no shared maker-checker, scheduled collection, accounting, permission or route implementation changed. Safe to commit as this scoped fix based on these checks; live HTTP/database integration and Spring transaction-proxy behavior were not tested. Nothing was committed, pushed or submitted. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index e0a03f6af87..5312e4651db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -363,7 +363,9 @@ public String handleGSIMCommands(@PathParam("parentAccountId") final Long parent + "Optional Fields: note, withdrawBalance, paymentTypeId, accountNumber, checkNumber, routingCode, receiptNumber, bankNumber\n\n" + "Calculate Interest on Savings Account:\n\n" + "Calculates interest earned on a savings account based on todays date. It does not attempt to post or credit the interest on the account. That is responsibility of the Post Interest API that will likely be called by overnight process.\n\n" - + "Post Interest on Savings Account:\n\n" + + "Annual savings charges:\n\n" + + "The applyAnnualFees command is unsupported and returns HTTP 400 with error.msg.command.unsupported. " + + "Annual savings charges are processed through the configured scheduler.\n\n" + "Post Interest on Savings Account:\n\n" + "Calculates and Posts interest earned on a savings account based on today's date and whether an interest posting or crediting event is due.\n\n" + "Block Savings Account:\n\n" + "Blocks Savings account from all types of credit and debit transactions\n\n" + "Unblock Savings Account:\n\n" @@ -379,6 +381,7 @@ public String handleGSIMCommands(@PathParam("parentAccountId") final Long parent @AlternativeOperationId("handleCommands_6") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.PostSavingsAccountsAccountIdRequest.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.PostSavingsAccountsAccountIdResponse.class))) + @ApiResponse(responseCode = "400", description = "Unsupported applyAnnualFees command: error.msg.command.unsupported") public String handleCommands(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, @QueryParam("command") @Parameter(description = "command") final String commandParam, @Parameter(hidden = true) final String apiRequestBodyAsJson) { @@ -409,7 +412,9 @@ public String handleCommands(@PathParam("accountId") @Parameter(description = "a + "Optional Fields: note, withdrawBalance, paymentTypeId, accountNumber, checkNumber, routingCode, receiptNumber, bankNumber\n\n" + "Calculate Interest on Savings Account:\n\n" + "Calculates interest earned on a savings account based on todays date. It does not attempt to post or credit the interest on the account. That is responsibility of the Post Interest API that will likely be called by overnight process.\n\n" - + "Post Interest on Savings Account:\n\n" + + "Annual savings charges:\n\n" + + "The applyAnnualFees command is unsupported and returns HTTP 400 with error.msg.command.unsupported. " + + "Annual savings charges are processed through the configured scheduler.\n\n" + "Post Interest on Savings Account:\n\n" + "Calculates and Posts interest earned on a savings account based on today's date and whether an interest posting or crediting event is due.\n\n" + "Block Savings Account:\n\n" + "Blocks Savings account from all types of credit and debit transactions\n\n" + "Unblock Savings Account:\n\n" @@ -425,6 +430,7 @@ public String handleCommands(@PathParam("accountId") @Parameter(description = "a @AlternativeOperationId("handleCommands_7") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.PostSavingsAccountsAccountIdRequest.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.PostSavingsAccountsAccountIdResponse.class))) + @ApiResponse(responseCode = "400", description = "Unsupported applyAnnualFees command: error.msg.command.unsupported") public String handleCommands(@PathParam("externalId") @Parameter(description = "externalId") final String externalId, @QueryParam("command") @Parameter(description = "command") final String commandParam, @Parameter(hidden = true) final String apiRequestBodyAsJson) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java index b726b9250b4..f68216ba0bf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ApplyAnnualFeeSavingsAccountCommandHandler.java @@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.exception.UnsupportedCommandException; import org.apache.fineract.commands.handler.NewCommandSourceHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -38,13 +39,6 @@ public class ApplyAnnualFeeSavingsAccountCommandHandler implements NewCommandSou @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - // TODO: why do we keep this class when we literally do nothing here?!? - // final LocalDate annualFeeTransactionDate = - // command.localDateValueOfParameterNamed("annualFeeTransactionDate"); - - // return - // this.writePlatformService.applyAnnualFee(command.getSavingsId(), - // annualFeeTransactionDate); - return null; + throw new UnsupportedCommandException("applyAnnualFees", "Annual savings charges are processed through the configured scheduler."); } } diff --git a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm index 57d3037968c..926a4c6a726 100644 --- a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm +++ b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm @@ -28293,6 +28293,18 @@

Calculate Interest on Savings Account

+
+
+

Annual savings charges

+

POST /v1/savingsaccounts/{accountId}?command=applyAnnualFees + is unsupported and returns HTTP 400 with error code error.msg.command.unsupported + (global code validation.msg.validation.errors.exist). + Annual savings charges are processed through the configured scheduler using charges already attached to the account. + The manual command does not create charges, charge payments, savings transactions or journal entries. + The same rejection applies to the external-ID and GSIM command routes.

+
+
+  
diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java new file mode 100644 index 00000000000..48cf28e08a1 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsAnnualFeeCommandTest.java @@ -0,0 +1,121 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.ws.rs.core.Response; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.exception.UnsupportedCommandException; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exceptionmapper.UnsupportedCommandExceptionMapper; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.handler.ApplyAnnualFeeSavingsAccountCommandHandler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SavingsAnnualFeeCommandTest { + + @Mock + private PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + @Mock + private SavingsAccountWritePlatformService writePlatformService; + @Mock + private SavingsAccountReadPlatformService savingsAccountReadPlatformService; + @Mock + private DefaultToApiJsonSerializer toApiJsonSerializer; + @InjectMocks + private SavingsAccountsApiResource resource; + + @ParameterizedTest + @ValueSource(strings = { "accountId", "externalId", "gsim" }) + void rejectsAnnualFeesWithStandardErrorBeforeAnyFinancialWrite(String route) { + var handler = new ApplyAnnualFeeSavingsAccountCommandHandler(writePlatformService); + when(commandsSourceWritePlatformService.logCommandSource(any())).thenAnswer(invocation -> { + CommandWrapper wrapper = invocation.getArgument(0); + assertThat(wrapper.actionName()).isEqualTo("APPLYANNUALFEE"); + assertThat(wrapper.entityName()).isEqualTo("SAVINGSACCOUNT"); + assertThat(wrapper.getSavingsId()).isEqualTo(42L); + return handler.processCommand(null); + }); + if ("externalId".equals(route)) { + when(savingsAccountReadPlatformService.retrieveAccountIdByExternalId(any())).thenReturn(42L); + } + UnsupportedCommandException exception = assertThrows(UnsupportedCommandException.class, () -> { + switch (route) { + case "externalId" -> resource.handleCommands("savings-reference", "applyAnnualFees", "{}"); + case "gsim" -> resource.handleGSIMCommands(42L, "applyAnnualFees", "{}"); + default -> resource.handleCommands(42L, "applyAnnualFees", "{}"); + } + }); + try (Response response = new UnsupportedCommandExceptionMapper().toResponse(exception)) { + assertThat(response.getStatus()).isEqualTo(400); + ApiGlobalErrorResponse error = (ApiGlobalErrorResponse) response.getEntity(); + assertThat(error.getUserMessageGlobalisationCode()).isEqualTo("validation.msg.validation.errors.exist"); + assertThat(error.getErrors()).singleElement().satisfies(detail -> { + assertThat(detail.getUserMessageGlobalisationCode()).isEqualTo("error.msg.command.unsupported"); + assertThat(detail.getDeveloperMessage()).contains("configured scheduler"); + }); + } + // All charge payments, savings transactions and journal posting are behind this write-service boundary. + verifyNoInteractions(writePlatformService, toApiJsonSerializer); + } + + @ParameterizedTest + @CsvSource({ "activate, ACTIVATE", "calculateInterest, CALCULATEINTEREST", "postInterest, POSTINTEREST", "close, CLOSE" }) + void unrelatedSavingsCommandsStillDispatch(String command, String action) { + CommandProcessingResult result = new CommandProcessingResultBuilder().withSavingsId(42L).build(); + when(commandsSourceWritePlatformService.logCommandSource(any())).thenAnswer(invocation -> { + CommandWrapper wrapper = invocation.getArgument(0); + assertThat(wrapper.actionName()).isEqualTo(action); + assertThat(wrapper.entityName()).isEqualTo("SAVINGSACCOUNT"); + return result; + }); + when(toApiJsonSerializer.serialize(result)).thenReturn("serialized-result"); + assertThat(resource.handleCommands(42L, command, "{}")).isEqualTo("serialized-result"); + } + + @Test + void openApiDocumentsTheRejectionForBothAccountIdentifiers() throws Exception { + for (Class identifierType : new Class[] { Long.class, String.class }) { + Operation operation = SavingsAccountsApiResource.class.getMethod("handleCommands", identifierType, String.class, String.class) + .getAnnotation(Operation.class); + assertThat(operation.description()).contains("applyAnnualFees", "HTTP 400", "error.msg.command.unsupported", + "configured scheduler"); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java new file mode 100644 index 00000000000..6366ae4a72b --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsAnnualFeeMakerCheckerTest.java @@ -0,0 +1,267 @@ +/** + * 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.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.fineract.batch.exception.ErrorInfo; +import org.apache.fineract.commands.configuration.RetryConfigurationAssembler; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; +import org.apache.fineract.commands.exception.UnsupportedCommandException; +import org.apache.fineract.commands.provider.CommandHandlerProvider; +import org.apache.fineract.commands.service.CommandSourceService; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.IdempotencyKeyResolver; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformServiceImpl; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +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.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exceptionmapper.DefaultExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.RollbackTransactionNotApprovedExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.UnsupportedCommandExceptionMapper; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.TransactionBoundApplicationEventPublisher; +import org.apache.fineract.infrastructure.jobs.service.SchedulerJobRunnerReadService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +class SavingsAnnualFeeMakerCheckerTest { + + private final ConfigurationDomainService configuration = mock(ConfigurationDomainService.class); + private final CommandSourceRepository repository = mock(CommandSourceRepository.class); + private final PlatformSecurityContext security = mock(PlatformSecurityContext.class); + private final CommandHandlerProvider handlers = mock(CommandHandlerProvider.class); + private final SavingsAccountWritePlatformService savings = mock(SavingsAccountWritePlatformService.class); + private final AppUser user = mock(AppUser.class); + private final IdempotencyKeyResolver idempotency = mock(IdempotencyKeyResolver.class); + @SuppressWarnings("unchecked") + private final ToApiJsonSerializer resultSerializer = mock(ToApiJsonSerializer.class); + private final List savedStates = new ArrayList<>(); + private final StaticApplicationContext applicationContext = new StaticApplicationContext(); + private CommandSource savedSource; + private ErrorHandler errors; + private PortfolioCommandSourceWritePlatformServiceImpl commands; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test Tenant", "UTC", null)); + newRequest(); + applicationContext.getBeanFactory().registerSingleton("unsupported", new UnsupportedCommandExceptionMapper()); + applicationContext.getBeanFactory().registerSingleton("awaitingApproval", new RollbackTransactionNotApprovedExceptionMapper()); + errors = new ErrorHandler(applicationContext, new DefaultExceptionMapper()); + FromJsonHelper json = new FromJsonHelper(); + CommandSourceService sources = new CommandSourceService(configuration, repository, errors, json); + RetryConfigurationAssembler retries = mock(RetryConfigurationAssembler.class); + when(retries.getRetryConfigurationForExecuteCommand()).thenReturn(Retry.of("test", RetryConfig.custom().maxAttempts(1).build())); + @SuppressWarnings("unchecked") + ToApiJsonSerializer> hookSerializer = mock(ToApiJsonSerializer.class); + SynchronousCommandProcessingService processing = new SynchronousCommandProcessingService(security, applicationContext, + mock(TransactionBoundApplicationEventPublisher.class), hookSerializer, resultSerializer, configuration, handlers, + idempotency, sources, retries, new FineractRequestContextHolder()); + commands = new PortfolioCommandSourceWritePlatformServiceImpl(security, repository, json, processing, + mock(SchedulerJobRunnerReadService.class), configuration, List.of()); + when(security.authenticatedUser(any(CommandWrapper.class))).thenReturn(user); + when(security.authenticatedUser()).thenReturn(user); + when(user.getId()).thenReturn(2L); + when(idempotency.resolve(any())).thenReturn("annual-fee-test"); + when(handlers.getHandler("SAVINGSACCOUNT", "APPLYANNUALFEE")).thenReturn(new ApplyAnnualFeeSavingsAccountCommandHandler(savings)); + when(repository.saveAndFlush(any(CommandSource.class))).thenAnswer(invocation -> { + savedSource = invocation.getArgument(0); + if (savedSource.getId() == null) { + savedSource.setId(101L); + } + // Snapshot now: the same entity is mutated again before the final save. + savedStates.add(savedSource.getStatusEnum()); + return savedSource; + }); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + ThreadLocalContextUtil.reset(); + applicationContext.close(); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void annualFeeRejectsBeforeMakerCheckerValidationAndNeverQueues(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask(anyString())).thenReturn(makerChecker); + + UnsupportedCommandException exception = assertThrows(UnsupportedCommandException.class, + () -> commands.logCommandSource(annualFee())); + + assertUnsupported(exception); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.ERROR); + assertThat(savedSource.isAwaitingApproval()).isFalse(); + assertThat(savedSource.getResultStatusCode()).isEqualTo(400); + assertThat(savedSource.getResult()).contains("error.msg.command.unsupported"); + assertThat(savedSource.getChecker()).isNull(); + verify(configuration, never()).isMakerCheckerEnabledForTask(anyString()); + verify(user).validateHasPermissionTo("APPLYANNUALFEE_SAVINGSACCOUNT"); + verifyNoInteractions(savings, resultSerializer); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void approvingPreviouslyQueuedAnnualFeeRejectsWithoutSuccessfulResult(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask(anyString())).thenReturn(makerChecker); + AppUser originalMaker = mock(AppUser.class); + when(originalMaker.getId()).thenReturn(1L); + CommandSource pending = CommandSource.builder().actionName("APPLYANNUALFEE").entityName("SAVINGSACCOUNT") + .resourceId(42L).savingsId(42L).resourceGetUrl(annualFee().getHref()).commandAsJson("{}").maker(originalMaker).idempotencyKey("previously-queued") + .status(CommandProcessingResultType.AWAITING_APPROVAL.getValue()).build(); + pending.setId(101L); + when(repository.findById(101L)).thenReturn(Optional.of(pending)); + when(repository.findByActionNameAndEntityNameAndIdempotencyKey("APPLYANNUALFEE", "SAVINGSACCOUNT", "previously-queued")) + .thenReturn(pending); + + UnsupportedCommandException exception = assertThrows(UnsupportedCommandException.class, () -> commands.approveEntry(101L)); + + assertUnsupported(exception); + assertThat(savedStates).containsExactly(CommandProcessingResultType.ERROR); + assertThat(pending.isAwaitingApproval()).isFalse(); + assertThat(pending.isChecked()).isFalse(); + assertThat(pending.getChecker()).isNull(); + assertThat(pending.getResultStatusCode()).isEqualTo(400); + verify(user).validateHasCheckerPermissionTo("APPLYANNUALFEE_SAVINGSACCOUNT"); + verify(configuration, never()).isMakerCheckerEnabledForTask(anyString()); + verifyNoInteractions(idempotency, savings, resultSerializer); + } + + @Test + void supportedActivationWithoutMakerCheckerStillSucceeds() { + configureActivation(false); + CommandProcessingResult result = commands.logCommandSource(activation()); + assertThat(result.getSavingsId()).isEqualTo(42L); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.PROCESSED); + assertThat(savedSource.getResultStatusCode()).isEqualTo(200); + verify(savings).activate(eq(42L), any(JsonCommand.class)); + } + + @Test + void supportedActivationStillQueuesAndCanBeApprovedWithMakerChecker() { + configureActivation(true); + RollbackTransactionNotApprovedException awaiting = assertThrows(RollbackTransactionNotApprovedException.class, + () -> commands.logCommandSource(activation())); + assertThat(errors.handle(awaiting).getStatusCode()).isEqualTo(200); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, + CommandProcessingResultType.AWAITING_APPROVAL); + assertThat(savedSource.isAwaitingApproval()).isTrue(); + CommandSource pending = savedSource; + when(repository.findById(101L)).thenReturn(Optional.of(pending)); + when(repository.findByActionNameAndEntityNameAndIdempotencyKey("ACTIVATE", "SAVINGSACCOUNT", "annual-fee-test")) + .thenReturn(pending); + when(configuration.isSameMakerCheckerEnabled()).thenReturn(true); + newRequest(); + + CommandProcessingResult approved = commands.approveEntry(101L); + + assertThat(approved.getSavingsId()).isEqualTo(42L); + assertThat(pending.isChecked()).isTrue(); + assertThat(pending.getChecker()).isSameAs(user); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.AWAITING_APPROVAL, + CommandProcessingResultType.PROCESSED); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void legacyNullHandlerReachedMakerCheckerOnlyAfterExecuting(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask(anyString())).thenReturn(makerChecker); + boolean[] executed = { false }; + when(handlers.getHandler("SAVINGSACCOUNT", "APPLYANNUALFEE")).thenReturn(command -> { + executed[0] = true; + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING); + return null; + }); + RuntimeException exception = assertThrows(RuntimeException.class, () -> commands.logCommandSource(annualFee())); + assertThat(executed[0]).isTrue(); + if (makerChecker) { + assertThat(exception).isInstanceOf(RollbackTransactionNotApprovedException.class); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(200); + assertThat(savedSource.isAwaitingApproval()).isTrue(); + } else { + assertThat(exception).isInstanceOf(NullPointerException.class); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(500); + assertThat(savedSource.getStatusEnum()).isEqualTo(CommandProcessingResultType.ERROR); + } + } + + private void configureActivation(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask("ACTIVATE_SAVINGSACCOUNT")).thenReturn(makerChecker); + when(handlers.getHandler("SAVINGSACCOUNT", "ACTIVATE")).thenReturn(new ActivateSavingsAccountCommandHandler(savings)); + when(savings.activate(eq(42L), any(JsonCommand.class))) + .thenAnswer(invocation -> new CommandProcessingResultBuilder().withEntityId(42L).withSavingsId(42L).build()); + when(resultSerializer.serializeResult(any())).thenReturn("{\"savingsId\":42}"); + } + + private void assertUnsupported(UnsupportedCommandException exception) { + ErrorInfo error = errors.handle(exception); + assertThat(error.getStatusCode()).isEqualTo(400); + assertThat(error.getMessage()).contains("validation.msg.validation.errors.exist", "error.msg.command.unsupported"); + } + + private CommandWrapper annualFee() { + return new CommandWrapperBuilder().withJson("{}").savingsAccountApplyAnnualFees(42L).build(); + } + + private CommandWrapper activation() { + return new CommandWrapperBuilder().withJson("{}").savingsAccountActivation(42L).build(); + } + + private void newRequest() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java new file mode 100644 index 00000000000..9f92f797825 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/jobs/SavingsAnnualFeeSchedulerTest.java @@ -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.portfolio.savings.jobs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.savings.data.SavingsAccountAnnualFeeData; +import org.apache.fineract.portfolio.savings.jobs.applyannualfeeforsavings.ApplyAnnualFeeForSavingsTasklet; +import org.apache.fineract.portfolio.savings.jobs.payduesavingscharges.PayDueSavingsChargesTasklet; +import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; + +class SavingsAnnualFeeSchedulerTest { + + private final SavingsAccountChargeReadPlatformService readService = mock(SavingsAccountChargeReadPlatformService.class); + private final SavingsAccountWritePlatformService writeService = mock(SavingsAccountWritePlatformService.class); + private final SavingsAccountAnnualFeeData charge = SavingsAccountAnnualFeeData.instance(7L, 42L, "account", LocalDate.of(2025, 1, 1)); + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test Tenant", "UTC", null)); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + void annualFeeJobStillCollectsExistingDueChargeThroughWriteService() throws Exception { + when(readService.retrieveChargesWithAnnualFeeDue()).thenReturn(List.of(charge)); + assertThat(new ApplyAnnualFeeForSavingsTasklet(readService, writeService).execute(null, null)).isEqualTo(RepeatStatus.FINISHED); + verify(writeService).applyAnnualFee(7L, 42L); + verifyNoMoreInteractions(writeService); + } + + @Test + void dueChargesJobStillUsesExistingCollectionOperation() throws Exception { + when(readService.retrieveChargesWithDue()).thenReturn(List.of(charge)); + assertThat(new PayDueSavingsChargesTasklet(readService, writeService).execute(null, null)).isEqualTo(RepeatStatus.FINISHED); + verify(writeService).applyChargeDue(7L, 42L); + verifyNoMoreInteractions(writeService); + } +} From 44a99c3c33e9347a04e687f2585fac81316183f4 Mon Sep 17 00:00:00 2001 From: ssali jamil Date: Fri, 25 Sep 2026 07:34:07 +0300 Subject: [PATCH 5/6] feat(savings): enforce deposit monetary authority --- docs/changes/savings-deposit-authority.md | 297 +++++++++++ .../commands/domain/CommandWrapper.java | 15 + .../domain/SavingsDepositCommandEnvelope.java | 109 +++++ .../SavingsDepositExecutionContext.java | 25 + .../commands/domain/SavingsDepositOrigin.java | 24 + .../handler/SavingsDepositCommandHandler.java | 29 ++ .../service/CommandSourceService.java | 29 +- .../service/CommandWrapperBuilder.java | 13 +- ...CommandSourceWritePlatformServiceImpl.java | 21 +- .../service/AuditReadPlatformServiceImpl.java | 4 + .../SavingsTransactionImportHandler.java | 4 +- ...SavingsAccountTransactionsApiResource.java | 4 +- .../DepositSavingsAccountCommandHandler.java | 37 +- .../service/SavingsDepositAuditTest.java | 62 +++ .../service/SavingsDepositEnvelopeTest.java | 54 ++ .../savings/api/SavingsDepositOriginTest.java | 156 ++++++ .../handler/SavingsDepositAuthorityTest.java | 463 ++++++++++++++++++ 17 files changed, 1333 insertions(+), 13 deletions(-) create mode 100644 docs/changes/savings-deposit-authority.md create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositExecutionContext.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositOrigin.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsDepositCommandHandler.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositEnvelopeTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsDepositOriginTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java diff --git a/docs/changes/savings-deposit-authority.md b/docs/changes/savings-deposit-authority.md new file mode 100644 index 00000000000..0736f47e017 --- /dev/null +++ b/docs/changes/savings-deposit-authority.md @@ -0,0 +1,297 @@ +# Savings deposit monetary authority + +## Status and scope + +Implemented on `feat/savings-deposit-authority`, based on synchronized `dev` at +`cbc78c0a3cd6d7dea8d3cbee4c54ac6782bf7333`. The base contains savings status filtering +(PR #3) and unsupported annual-fee rejection (PR #5). No commit, push or PR was made. + +The original investigation stopped because API deposits and spreadsheet imports +both used `DEPOSIT_SAVINGSACCOUNT` without a persisted discriminator. The subsequent +approved design resolves that stop condition using trusted server metadata in the +existing command JSON column. This report preserves the call-path and policy +findings and describes the implementation replacing that initial stop condition. + +Only ordinary staff/API savings deposits, including ordinary batch deposits, are +enrolled in `MonetaryAuthorityType.DEPOSITS`. Spreadsheet imports retain their +existing validation and permissions. Transfers, internal credits, interest, +scheduled jobs, GSIM, fixed/recurring deposits, charge processing and withdrawals +are not enrolled. No financial write/domain service or authority model changed. + +## Construction and trust boundary + +- `SavingsAccountTransactionsApiResource.transaction`: its ordinary `deposit` + branch assigns `SavingsDepositOrigin.STAFF_API`. Both numeric and external + account identifiers use this branch. +- `SavingsAccountTransactionCommandStrategy.execute`: ordinary batch deposits + call that same resource. Additional query parameters or headers are not origin + inputs. +- `SavingsTransactionImportHandler.importEntity`: only the spreadsheet deposit + branch assigns `SPREADSHEET_IMPORT`. The withdrawal branch is unchanged. +- `CommandWrapperBuilder.withSavingsDepositOrigin` passes the enum to an immutable + `CommandWrapper` field. Existing constructors remain compatible and default to + no origin; the general `savingsAccountDeposit` builder does not default to a + trusted origin. Future server callers must explicitly choose their origin. + +The reserved top-level `_serverCommand` key in any incoming deposit business JSON +is rejected before persistence, including batch requests and JSON null values for +that key. Neither JSON, query parameters nor headers set the enum. Approval accepts +an audit ID and approve/reject action, not a replacement deposit payload. + +## Persistence, execution, approval and retry + +`PortfolioCommandSourceWritePlatformServiceImpl.logCommandSource` preserves the +existing deposit permission check and creates the ordinary flat `JsonCommand`. +`SynchronousCommandProcessingService.executeCommandAttempt` identifies the original +maker using the authenticated submitter and obtains a `CommandSource` through +`CommandSourceService.saveInitial` (or `getInitialCommandSource` for enclosing +batch transactions). + +`CommandSourceService.getInitialCommandSource` reserves the client metadata key +before audit masking and uses `SavingsDepositCommandEnvelope` to encode only +`DEPOSIT_SAVINGSACCOUNT`: + +```json +{ + "_serverCommand": { "version": 1, "origin": "STAFF_API" }, + "payload": { "transactionAmount": 50, "locale": "en" } +} +``` + +The complete original business payload is retained, not just the illustrative +fields above. Maker remains in `CommandSource.maker`; it is not duplicated in JSON. +The existing `m_portfolio_command_source.command_as_json` column is `TEXT` +(`0001_initial_schema.xml`, line 3067). No database migration is necessary. Unrelated +commands keep their original flat storage and execution paths. + +`CommandSourceService.processCommandAndSaveResult` performs the single trusted +metadata decode for each deposit execution. It reconstructs a flat `JsonCommand` +using persisted account/resource identifiers and passes a +`SavingsDepositExecutionContext(origin, maker)` through the specialized +`SavingsDepositCommandHandler` interface. Other handlers retain the existing +`NewCommandSourceHandler.processCommand` contract and behavior. + +`DepositSavingsAccountCommandHandler.processDeposit`: + +1. Rejects missing context, origin or maker. +2. Bypasses monetary authority only for the exact trusted `SPREADSHEET_IMPORT` + origin; the original financial service still performs all business validation. +3. For `STAFF_API`, runs the existing flat-payload transaction validator, reads the + savings account currency and checks the original maker's current DEPOSITS + authority against the requested transaction amount. +4. Calls the unchanged financial deposit service only if allowed. This precedes + payment-detail creation, transaction/account changes, journals and business events. + +The context-free handler method fails closed as defense in depth. No security +impersonation, new ThreadLocal or global mutable state was added. + +Maker-checker validation follows handler execution in `CommandSourceService`. +Thus unauthorized deposits never enter `AWAITING_APPROVAL`. The normal initial +idempotency/audit record may be saved as `UNDER_PROCESSING` and subsequently +`ERROR`; this is not a pending approval entry. Authorized submissions follow the +existing execution-and-rollback maker-checker mechanism. + +`approveEntry` retains checker permissions and same-maker/checker validation. +It unwraps JSON for presentation/hooks only; this does not establish execution +trust. Execution independently validates the stored envelope and uses +`CommandSource.maker`, never the checker, for monetary policy. The checker retains +normal audit attribution. A checker with greater authority cannot override the +maker, and reductions/removal of maker authority before approval are re-evaluated. + +Retries load the existing command source through `COMMAND_SOURCE_ID` and use its +origin, payload, account identifiers and maker. Replacement request metadata is +not policy input. Reject/delete operations keep their existing authorization and +do not require execution decoding, so legacy pending commands can be cancelled. + +## Existing policy semantics + +`NsimbiMonetaryAuthorityPolicyService.allows` and `NsimbiUserMonetaryAuthority` +are unchanged. Minimum and maximum are inclusive `BigDecimal` comparisons. One +null bound is open-ended; both null is unconfigured and denied. Missing authority, +null policy inputs and contradictory persisted bounds fail closed. Normal entity +construction rejects inverted bounds. There is no authority active/inactive flag. + +The currency comes from the savings account. The policy performs no currency +conversion, trimming or uppercasing. Its repository query uses the original maker +ID, DEPOSITS and the currency code; database equality/collation remains unchanged. + +## Compatibility and API behavior + +| Situation | Behavior | +| --- | --- | +| Ordinary API deposit within maker's inclusive bounds, maker-checker off | Existing deposit executes | +| Ordinary API deposit within bounds, maker-checker on | Existing approval workflow; maker checked again at approval | +| Below minimum, above maximum, missing/unconfigured authority or currency mismatch | HTTP 403 domain error before financial writes and approval queuing | +| Trusted spreadsheet deposit | Existing behavior; no monetary-authority lookup | +| Legacy pending API/import deposit without trusted origin | HTTP 403; cancel and resubmit | +| Malformed/unknown-origin/unsupported-version envelope | Same actionable origin error; no parsing error or HTTP 500 | +| Completed legacy deposit history | Flat business JSON remains readable | +| Other command types and internal financial paths | Existing behavior and flat command storage | + +Domain errors use `GeneralPlatformDomainRuleException` and the existing +`PlatformDomainRuleExceptionMapper` (HTTP 403): + +- `error.msg.savings.deposit.monetary.authority.denied` +- `error.msg.savings.deposit.untrusted.origin`: cancel and resubmit the deposit. +- `error.msg.savings.deposit.reserved.metadata`: remove the reserved client field. + +Malformed incoming business JSON uses the existing `InvalidJsonException`. +Historical origin is never guessed from role, URL, payload shape or audit history, +including historical spreadsheet commands. Pending commands must be resubmitted +through the proper entry point; origin is not backfilled. + +`AuditReadPlatformServiceImpl.AuditMapper` uses the codec's read-only `forDisplay` +for savings deposits. Audit detail/list and maker-checker query responses retain +flat business payloads and do not expose the metadata envelope. Legacy flat +payloads are returned unchanged. Malformed envelopes without a usable payload +present `{}`; raw metadata remains available in the internal persisted command +record. Read/display unwrapping never authorizes execution. + +## Excluded paths and source evidence + +- `AccountTransfersWritePlatformServiceImpl` transfer/refund credits call + `SavingsAccountDomainService.handleDeposit` directly, bypassing the ordinary + deposit handler. +- Opening balances use the domain service from savings activation. Interest + posting uses its own command/service methods. Scheduled jobs use their existing + services. None calls the guarded ordinary deposit command. +- `GSIMDepositCommandHandler` uses entity `GSIMACCOUNT`; its shared financial + service remains unchanged. +- Fixed/recurring deposits, maturity, charges and withdrawal handlers/services + remain unchanged. No accounting or balance-calculation code changed. +- The only production `savingsAccountDeposit(...)` construction sites are the API + and spreadsheet import paths, and both now assign origin internally. These + callers do not request command JSON sanitization. + +## Security and deployment assumptions + +Trusted origin means server-constructed and database-persisted, not a cryptographic +signature. Database/JVM operators remain trusted under `SECURITY.md`. No client API +was found that edits stored command JSON. All nodes executing savings commands +must run this version before relying on enforcement; an old handler does not +implement the policy. A rollback also requires handling new enveloped pending +commands before they reach old flat-JSON readers. + +Authority is evaluated at execution using the existing repository and transaction +semantics; no new locking or snapshot of authority is introduced. This change does +not claim to serialize simultaneous authority edits and financial commits. + +## Validation + +Final validation on 2026-09-25: **116 tests passed, zero failures/errors/skips**. +Scoped Spotless checks passed; `git diff --check` passed. +Tests use real command services, handler, validator, policy and error mappers with +mocked persistence/financial writes. They are not a running-server or database-backed +transaction/rollback test. Real database commit/rollback, journal balances, HTTP +container binding and multi-node rollout were not exercised. Excluded-path evidence +combines focused regression tests and the call-path review above. + +### Exact final command and results + +```sh +./gradlew --offline --no-daemon --max-workers=1 --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-deposit-tests.gradle \ + :fineract-core:spotlessJavaApply :fineract-provider:spotlessJavaApply \ + :fineract-provider:test \ + --tests '*SavingsDeposit*Test' \ + --tests '*SavingsAnnualFee*Test' \ + --tests '*NsimbiUserMonetaryAuthorityTest' \ + --tests '*NsimbiMonetaryAuthorityPolicyServiceTest' \ + --tests '*CommandSourceServiceTest' \ + --tests '*SynchronousCommandProcessingServiceTest' \ + --tests '*SavingsAccountWritePlatformServiceJpaRepositoryImplTest' \ + --tests '*SavingsAccountTransfersServiceImplTest' \ + --tests '*FixedDepositAccountInterestCalculationServiceImplTest' \ + --tests '*CreateAccountTransferCommandStrategyTest' \ + :fineract-core:spotlessJavaCheck :fineract-provider:spotlessJavaCheck \ + -x :fineract-provider:resolve + +git diff --check +``` + +The temporary Gradle init script caps each test JVM at 768 MiB with one fork and +sets each module's Spotless Java target to the exact changed Java files listed +below. It does not change repository build configuration. The unrelated provider +Swagger resolution task is excluded; dependency compilation still runs. Final +build: **BUILD SUCCESSFUL in 1m 34s**, tests completed in 26.5s. Log: +`/tmp/deposit-verified-build.log`. Existing Gradle deprecation warnings remain. + +| Test class | Passed | +| --- | ---: | +| `FixedDepositAccountInterestCalculationServiceImplTest` | 2 | +| `SavingsAccountWritePlatformServiceJpaRepositoryImplTest` | 11 | +| `CreateAccountTransferCommandStrategyTest` | 1 | +| `CommandSourceServiceTest` | 3 | +| `SavingsDepositAuditTest` | 5 | +| `SavingsDepositEnvelopeTest` | 11 | +| `SynchronousCommandProcessingServiceTest` | 12 | +| `NsimbiUserMonetaryAuthorityTest` | 2 | +| `NsimbiMonetaryAuthorityPolicyServiceTest` | 1 | +| `SavingsAccountTransfersServiceImplTest` | 6 | +| `SavingsAnnualFeeCommandTest` | 8 | +| `SavingsDepositOriginTest` | 6 | +| `SavingsAnnualFeeMakerCheckerTest` | 8 | +| `SavingsDepositAuthorityTest` | 38 | +| `SavingsAnnualFeeSchedulerTest` | 2 | + +The four new test classes contain 60 cases. These cover inclusive boundaries, +missing/unconfigured/currency-mismatched/invalid authority, both maker-checker modes, +original-maker rechecks, higher-authority checker denial, authority changes, +API/batch/import construction, reserved metadata injection, query/header and approval +spoofing, trusted import approval, flat payload validation, audit compatibility, +legacy/malformed origins, retry/reject behavior, and excluded command dispatch. +The 18 annual-fee cases include the original 10 focused tests and 8 maker-checker +regressions. Remaining tests cover existing policy, command processing and savings +financial-service regressions. + +Earlier validation exposed a test-fixture enum setter compilation error and a +missing tenant timezone in the spreadsheet fixture; both were corrected. No failures +remain in the final run. + +Complete diff review found only the intended 12 production files, four new test +files and this report. No migrations, generated files, permissions, routes, +financial service implementations or unrelated changes are included. The branch is +ready for review/commit within the validation limits above; nothing was committed. + +## Exact changed files + +- `docs/changes/savings-deposit-authority.md` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositExecutionContext.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositOrigin.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsDepositCommandHandler.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java` +- `fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java` +- `fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/DepositSavingsAccountCommandHandler.java` +- `fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositEnvelopeTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsDepositOriginTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java` + +## Recommended commit and PR text + +Commit subject: `feat(savings): enforce maker deposit authority for staff API commands` + +PR title: `Enforce maker monetary authority for ordinary savings deposits` +(Add the project ticket identifier if required by the repository contribution workflow.) + +PR description: + +Ordinary API and batch savings deposits previously bypassed the existing DEPOSITS +monetary-authority policy. Persist server-assigned API/import origin in the existing +command JSON and enforce the original maker's current authority before financial +writes, including approval and retries. Spreadsheet imports and internal financial +paths retain their existing behavior. Legacy pending deposits without trusted origin +must be cancelled and resubmitted; audit payloads remain flat. No schema migration. + +Validation: focused command, origin, authority, maker-checker, audit and existing +savings regressions; scoped Spotless and `git diff --check`. Database-backed +transaction/rollback and running-server end-to-end tests were not run. + +No commit or PR has been created. For a future commit, retain the repository's +required sign-off and `Assisted-By: Codex-GPT-6` attribution. diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java index 243c4ff9f66..60af116ae28 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java @@ -45,6 +45,7 @@ public class CommandWrapper { private final Long commandId; + private final SavingsDepositOrigin savingsDepositOrigin; @SuppressWarnings("unused") private final Long officeId; private final Long groupId; @@ -92,6 +93,7 @@ public static CommandWrapper fromExistingCommand(final Long commandId, final Str private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId, final Long subresourceId, final String resourceGetUrl, final Long productId) { + this.savingsDepositOrigin = null; this.commandId = commandId; this.officeId = null; this.groupId = null; @@ -121,6 +123,18 @@ public CommandWrapper(final Long officeId, final Long groupId, final Long client final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId, final Set sanitizeJsonKeys) { + this(officeId, groupId, clientId, loanId, savingsId, actionName, entityName, entityId, subentityId, href, json, transactionId, + productId, templateId, creditBureauId, organisationCreditBureauId, jobName, idempotencyKey, loanExternalId, + sanitizeJsonKeys, null); + } + + public CommandWrapper(final Long officeId, final Long groupId, final Long clientId, final Long loanId, final Long savingsId, + final String actionName, final String entityName, final Long entityId, final Long subentityId, final String href, + final String json, final String transactionId, final Long productId, final Long templateId, final Long creditBureauId, + final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId, + final Set sanitizeJsonKeys, final SavingsDepositOrigin savingsDepositOrigin) { + + this.savingsDepositOrigin = savingsDepositOrigin; this.commandId = null; this.officeId = officeId; this.groupId = groupId; @@ -151,6 +165,7 @@ private CommandWrapper(final Long commandId, final String actionName, final Stri final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId, final Set sanitizeJsonKeys) { + this.savingsDepositOrigin = null; this.commandId = commandId; this.officeId = officeId; this.groupId = groupId; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java new file mode 100644 index 00000000000..0d71b4657ad --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java @@ -0,0 +1,109 @@ +/** + * 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.commands.domain; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; + +/** Versioned server metadata in command_as_json, restricted to DEPOSIT_SAVINGSACCOUNT. */ +public final class SavingsDepositCommandEnvelope { + + public static final String METADATA = "_serverCommand"; + public static final String UNTRUSTED_ORIGIN = "error.msg.savings.deposit.untrusted.origin"; + + private SavingsDepositCommandEnvelope() {} + + public static boolean appliesTo(String action, String entity) { + return "DEPOSIT".equals(action) && "SAVINGSACCOUNT".equals(entity); + } + + public static String encode(String json, SavingsDepositOrigin origin) { + JsonObject payload = clientPayload(json); + if (origin == null) { + throw untrustedOrigin(); + } + JsonObject metadata = new JsonObject(); + metadata.addProperty("version", 1); + metadata.addProperty("origin", origin.name()); + JsonObject envelope = new JsonObject(); + envelope.add(METADATA, metadata); + envelope.add("payload", payload); + return envelope.toString(); + } + + public static JsonObject clientPayload(String json) { + final JsonObject payload; + try { + payload = JsonParser.parseString(json).getAsJsonObject(); + } catch (RuntimeException exception) { + throw new InvalidJsonException(); + } + if (payload.has(METADATA)) { + throw new GeneralPlatformDomainRuleException("error.msg.savings.deposit.reserved.metadata", + "The _serverCommand property is reserved for server use and must not be supplied in a deposit request."); + } + return payload; + } + + public static Decoded decode(String json) { + try { + JsonObject envelope = JsonParser.parseString(json).getAsJsonObject(); + JsonObject metadata = envelope.getAsJsonObject(METADATA); + JsonElement version = metadata.get("version"); + JsonElement origin = metadata.get("origin"); + JsonObject payload = envelope.getAsJsonObject("payload"); + if (envelope.size() != 2 || metadata.size() != 2 || !version.isJsonPrimitive() || !version.getAsJsonPrimitive().isNumber() + || !"1".equals(version.getAsString()) || !origin.isJsonPrimitive() || !origin.getAsJsonPrimitive().isString() + || payload == null || payload.has(METADATA)) { + throw untrustedOrigin(); + } + return new Decoded(SavingsDepositOrigin.valueOf(origin.getAsString()), payload); + } catch (RuntimeException exception) { + throw untrustedOrigin(); + } + } + + /** Read-only compatibility: legacy flat history remains readable without establishing execution trust. */ + public static String forDisplay(String json) { + if (json == null || json.isBlank()) { + return json; + } + try { + JsonObject object = JsonParser.parseString(json).getAsJsonObject(); + if (!object.has(METADATA)) { + return json; + } + JsonElement payload = object.get("payload"); + return payload != null && payload.isJsonObject() ? payload.toString() : "{}"; + } catch (RuntimeException exception) { + return "{}"; + } + } + + public static GeneralPlatformDomainRuleException untrustedOrigin() { + return new GeneralPlatformDomainRuleException(UNTRUSTED_ORIGIN, + "This deposit command has untrusted or unsupported origin metadata. Cancel it and resubmit the deposit."); + } + + public record Decoded(SavingsDepositOrigin origin, JsonObject payload) { + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositExecutionContext.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositExecutionContext.java new file mode 100644 index 00000000000..185fa496342 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositExecutionContext.java @@ -0,0 +1,25 @@ +/** + * 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.commands.domain; + +import org.apache.fineract.useradministration.domain.AppUser; + +/** Identity and origin loaded from the persisted command, never from the checker or request payload. */ +public record SavingsDepositExecutionContext(SavingsDepositOrigin origin, AppUser maker) { +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositOrigin.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositOrigin.java new file mode 100644 index 00000000000..099ef6d88d0 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositOrigin.java @@ -0,0 +1,24 @@ +/** + * 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.commands.domain; + +/** Assigned only by trusted server-side command constructors. */ +public enum SavingsDepositOrigin { + STAFF_API, SPREADSHEET_IMPORT +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsDepositCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsDepositCommandHandler.java new file mode 100644 index 00000000000..a8d4e820f62 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsDepositCommandHandler.java @@ -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.commands.handler; + +import org.apache.fineract.commands.domain.SavingsDepositExecutionContext; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +/** Explicit trusted context for the ordinary savings deposit command only. */ +public interface SavingsDepositCommandHandler extends NewCommandSourceHandler { + + CommandProcessingResult processDeposit(JsonCommand command, SavingsDepositExecutionContext context); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 0b560528706..c9189b53189 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -30,9 +30,12 @@ import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositExecutionContext; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.handler.SavingsDepositCommandHandler; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -106,10 +109,18 @@ public CommandSource findCommandSource(CommandWrapper wrapper, String idempotenc public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) { CommandSource commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey, UNDER_PROCESSING.getValue(), false); + if (SavingsDepositCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { + // Reject client metadata before any audit masking could remove the reserved key. + SavingsDepositCommandEnvelope.clientPayload(jsonCommand.json()); + } sanitizeJson(commandSourceResult, wrapper.getSanitizeJsonKeys()); if (commandSourceResult.getCommandAsJson() == null) { commandSourceResult.setCommandAsJson("{}"); } + if (SavingsDepositCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { + commandSourceResult.setCommandAsJson( + SavingsDepositCommandEnvelope.encode(commandSourceResult.getCommandAsJson(), wrapper.getSavingsDepositOrigin())); + } return commandSourceResult; } @@ -117,7 +128,23 @@ public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand public CommandExecutionResult processCommandAndSaveResult(NewCommandSourceHandler handler, JsonCommand command, CommandSource commandSource, AppUser user, boolean isApprovedByChecker, BiConsumer resultUpdater) { - final CommandProcessingResult result = handler.processCommand(command); + final CommandProcessingResult result; + if (SavingsDepositCommandEnvelope.appliesTo(commandSource.getActionName(), commandSource.getEntityName())) { + var decoded = SavingsDepositCommandEnvelope.decode(commandSource.getCommandAsJson()); + if (!(handler instanceof SavingsDepositCommandHandler depositHandler)) { + throw SavingsDepositCommandEnvelope.untrustedOrigin(); + } + JsonCommand flatCommand = JsonCommand.fromExistingCommand(command.commandId(), decoded.payload().toString(), decoded.payload(), + fromApiJsonHelper, commandSource.getEntityName(), commandSource.getResourceId(), commandSource.getSubResourceId(), + commandSource.getGroupId(), commandSource.getClientId(), commandSource.getLoanId(), commandSource.getSavingsId(), + commandSource.getTransactionId(), commandSource.getResourceGetUrl(), commandSource.getProductId(), + commandSource.getCreditBureauId(), commandSource.getOrganisationCreditBureauId(), commandSource.getJobName(), + commandSource.getLoanExternalId()); + result = depositHandler.processDeposit(flatCommand, + new SavingsDepositExecutionContext(decoded.origin(), commandSource.getMaker())); + } else { + result = handler.processCommand(command); + } validateMakerChecker(commandSource, user, isApprovedByChecker, result); resultUpdater.accept(commandSource, result); return new CommandExecutionResult(result, saveResult(commandSource)); diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index fed93fe0e27..74d4a3dcae4 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -252,6 +252,7 @@ import java.util.Locale; import java.util.Set; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; import org.apache.fineract.infrastructure.accountnumberformat.service.AccountNumberFormatConstants; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.client.api.ClientApiConstants; @@ -260,6 +261,13 @@ public class CommandWrapperBuilder { + private SavingsDepositOrigin savingsDepositOrigin; + + public CommandWrapperBuilder withSavingsDepositOrigin(SavingsDepositOrigin origin) { + this.savingsDepositOrigin = origin; + return this; + } + private Long groupId; private Long clientId; private Long loanId; @@ -280,13 +288,14 @@ public class CommandWrapperBuilder { public CommandWrapper build() { return new CommandWrapper(null, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, null, null, - this.organisationCreditBureauId, this.jobName, null, this.loanExternalId, this.sanitizeJsonKeys); + this.organisationCreditBureauId, this.jobName, null, this.loanExternalId, this.sanitizeJsonKeys, this.savingsDepositOrigin); } public CommandWrapper build(String idempotencyKey) { return new CommandWrapper(null, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, null, null, - this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId, this.sanitizeJsonKeys); + this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId, this.sanitizeJsonKeys, + this.savingsDepositOrigin); } public CommandWrapperBuilder updateCreditBureau() { diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index 604a07a7a4d..a5130199ff2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -26,6 +26,7 @@ import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.UnsupportedCommandException; @@ -93,12 +94,20 @@ public CommandProcessingResult approveEntry(final Long makerCheckerId) { commandSourceInput.getSavingsId(), commandSourceInput.getTransactionId(), commandSourceInput.getCreditBureauId(), commandSourceInput.getOrganisationCreditBureauId(), commandSourceInput.getIdempotencyKey(), commandSourceInput.getLoanExternalId()); - final JsonElement parsedCommand = this.fromApiJsonHelper.parse(commandSourceInput.getCommandAsJson()); - final JsonCommand command = JsonCommand.fromExistingCommand(makerCheckerId, commandSourceInput.getCommandAsJson(), parsedCommand, - this.fromApiJsonHelper, commandSourceInput.getEntityName(), commandSourceInput.getResourceId(), - commandSourceInput.getSubResourceId(), commandSourceInput.getGroupId(), commandSourceInput.getClientId(), - commandSourceInput.getLoanId(), commandSourceInput.getSavingsId(), commandSourceInput.getTransactionId(), - commandSourceInput.getResourceGetUrl(), commandSourceInput.getProductId(), commandSourceInput.getCreditBureauId(), + // Unwrap for presentation (including hooks); execution separately establishes trust in the persisted metadata. + String executionJson = commandSourceInput.getCommandAsJson(); + if (SavingsDepositCommandEnvelope.appliesTo(commandSourceInput.getActionName(), commandSourceInput.getEntityName())) { + executionJson = SavingsDepositCommandEnvelope.forDisplay(executionJson); + if (executionJson == null || executionJson.isBlank()) { + executionJson = "{}"; + } + } + final JsonElement parsedCommand = this.fromApiJsonHelper.parse(executionJson); + final JsonCommand command = JsonCommand.fromExistingCommand(makerCheckerId, executionJson, parsedCommand, this.fromApiJsonHelper, + commandSourceInput.getEntityName(), commandSourceInput.getResourceId(), commandSourceInput.getSubResourceId(), + commandSourceInput.getGroupId(), commandSourceInput.getClientId(), commandSourceInput.getLoanId(), + commandSourceInput.getSavingsId(), commandSourceInput.getTransactionId(), commandSourceInput.getResourceGetUrl(), + commandSourceInput.getProductId(), commandSourceInput.getCreditBureauId(), commandSourceInput.getOrganisationCreditBureauId(), commandSourceInput.getJobName(), commandSourceInput.getLoanExternalId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java index 22bc22a05c1..1eda538c66f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java @@ -39,6 +39,7 @@ import org.apache.fineract.commands.data.AuditSearchData; import org.apache.fineract.commands.data.ProcessingResultLookup; import org.apache.fineract.commands.data.request.AuditRequest; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.infrastructure.core.data.PaginationParameters; import org.apache.fineract.infrastructure.core.data.PaginationParametersDataValidator; @@ -167,6 +168,9 @@ public AuditData mapRow(final ResultSet rs, @SuppressWarnings("unused") final in ZonedDateTime madeOnDate = madeOnDateUTC != null ? madeOnDateUTC.toZonedDateTime() : madeOnDateTenant; ZonedDateTime checkedOnDate = checkedOnDateUTC != null ? checkedOnDateUTC.toZonedDateTime() : checkedOnDateTenant; + if (SavingsDepositCommandEnvelope.appliesTo(actionName, entityName)) { + commandAsJson = SavingsDepositCommandEnvelope.forDisplay(commandAsJson); + } return new AuditData(id, actionName, entityName, resourceId, subresourceId, maker, madeOnDate, checker, checkedOnDate, processingResult, commandAsJson, officeName, groupLevelName, groupName, clientName, loanAccountNo, savingsAccountNo, clientId, loanId, resourceGetUrl, ip); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java index 49c9534ce85..e138f32d545 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants; @@ -141,7 +142,8 @@ private Count importEntity(final Workbook workbook, final List builder.savingsAccountDeposit(resolvedSavingsId).build(); + case "deposit" -> + builder.savingsAccountDeposit(resolvedSavingsId).withSavingsDepositOrigin(SavingsDepositOrigin.STAFF_API).build(); case "gsimDeposit" -> builder.gsimSavingsAccountDeposit(resolvedSavingsId).build(); case "withdrawal" -> builder.savingsAccountWithdrawal(resolvedSavingsId).build(); case "force-withdrawal" -> builder.savingsAccountForceWithdrawal(resolvedSavingsId).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/DepositSavingsAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/DepositSavingsAccountCommandHandler.java index fad0d8abaf5..b8c00d5c77b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/DepositSavingsAccountCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/DepositSavingsAccountCommandHandler.java @@ -20,9 +20,17 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositExecutionContext; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.handler.SavingsDepositCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,13 +38,38 @@ @Service @CommandType(entity = "SAVINGSACCOUNT", action = "DEPOSIT") @RequiredArgsConstructor -public class DepositSavingsAccountCommandHandler implements NewCommandSourceHandler { +public class DepositSavingsAccountCommandHandler implements SavingsDepositCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; + private final NsimbiMonetaryAuthorityPolicyService monetaryAuthority; + private final SavingsAccountRepositoryWrapper accounts; + private final SavingsAccountTransactionDataValidator validator; + @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { + throw SavingsDepositCommandEnvelope.untrustedOrigin(); + } + + @Transactional + @Override + public CommandProcessingResult processDeposit(JsonCommand command, SavingsDepositExecutionContext context) { + if (context == null || context.origin() == null || context.maker() == null) { + throw SavingsDepositCommandEnvelope.untrustedOrigin(); + } + if (context.origin() != SavingsDepositOrigin.SPREADSHEET_IMPORT) { + if (context.origin() != SavingsDepositOrigin.STAFF_API) { + throw SavingsDepositCommandEnvelope.untrustedOrigin(); + } + validator.validate(command); + String currency = accounts.findOneWithNotFoundDetection(command.getSavingsId()).getCurrency().getCode(); + if (!monetaryAuthority.allows(context.maker().getId(), MonetaryAuthorityType.DEPOSITS, currency, + command.bigDecimalValueOfParameterNamed("transactionAmount"))) { + throw new GeneralPlatformDomainRuleException("error.msg.savings.deposit.monetary.authority.denied", + "The original submitter does not have DEPOSITS monetary authority for this amount and account currency."); + } + } return this.writePlatformService.deposit(command.getSavingsId(), command); } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java new file mode 100644 index 00000000000..609014a768c --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java @@ -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.commands.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import org.apache.fineract.commands.data.AuditData; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.jdbc.core.RowMapper; + +class SavingsDepositAuditTest { + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void auditMapperReturnsFlatPayloadForLegacyAndEnvelopedDeposits(boolean envelope) throws Exception { + String payload = "{\"transactionAmount\":50}"; + String stored = envelope ? SavingsDepositCommandEnvelope.encode(payload, SavingsDepositOrigin.STAFF_API) : payload; + assertThat(map("DEPOSIT", stored).getCommandAsJson()).isEqualTo(payload); + } + + @ParameterizedTest + @ValueSource(strings = { "WITHDRAWAL", "POSTINTEREST", "ACTIVATE" }) + void unrelatedAuditCommandsAreUntouched(String action) throws Exception { + String stored = "{\"_serverCommand\":\"ordinary data for another command\"}"; + assertThat(map(action, stored).getCommandAsJson()).isEqualTo(stored); + } + + private AuditData map(String action, String stored) throws Exception { + // Exercise the actual shared mapper used by audit detail, list and maker-checker queries. + var constructor = Class.forName(AuditReadPlatformServiceImpl.class.getName() + "$AuditMapper").getDeclaredConstructor(); + constructor.setAccessible(true); + @SuppressWarnings("unchecked") + RowMapper mapper = (RowMapper) constructor.newInstance(); + ResultSet row = mock(ResultSet.class); + when(row.getString("actionName")).thenReturn(action); + when(row.getString("entityName")).thenReturn("SAVINGSACCOUNT"); + when(row.getString("commandAsJson")).thenReturn(stored); + return mapper.mapRow(row, 0); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositEnvelopeTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositEnvelopeTest.java new file mode 100644 index 00000000000..2718c0e2d6c --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositEnvelopeTest.java @@ -0,0 +1,54 @@ +/** + * 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.commands.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SavingsDepositEnvelopeTest { + + @ParameterizedTest + @ValueSource(strings = { "null", "{}", "[]", "not json", "{\"_serverCommand\":null}", + "{\"_serverCommand\":{\"version\":\"1\",\"origin\":\"STAFF_API\"},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":1.5,\"origin\":\"STAFF_API\"},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":1,\"origin\":null},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":1,\"origin\":\"SPREADSHEET_IMPORT\"},\"payload\":null}", + "{\"_serverCommand\":{\"version\":1,\"origin\":\"STAFF_API\"},\"payload\":{\"_serverCommand\":{}}}" }) + void malformedMetadataFailsClosed(String stored) { + var failure = assertThrows(GeneralPlatformDomainRuleException.class, () -> SavingsDepositCommandEnvelope.decode(stored)); + assertThat(failure.getGlobalisationMessageCode()).isEqualTo(SavingsDepositCommandEnvelope.UNTRUSTED_ORIGIN); + } + + @Test + void originMustBeAssignedByServerAndEnvelopeDoesNotDuplicateMaker() { + assertThrows(GeneralPlatformDomainRuleException.class, () -> SavingsDepositCommandEnvelope.encode("{}", null)); + String stored = SavingsDepositCommandEnvelope.encode("{\"transactionAmount\":100}", SavingsDepositOrigin.STAFF_API); + assertThat(stored).doesNotContain("maker"); + var decoded = SavingsDepositCommandEnvelope.decode(stored); + assertThat(decoded.origin()).isEqualTo(SavingsDepositOrigin.STAFF_API); + assertThat(decoded.payload().get("transactionAmount").getAsInt()).isEqualTo(100); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsDepositOriginTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsDepositOriginTest.java new file mode 100644 index 00000000000..6ae818c8733 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsDepositOriginTest.java @@ -0,0 +1,156 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import org.apache.fineract.batch.command.internal.SavingsAccountTransactionCommandStrategy; +import org.apache.fineract.batch.domain.BatchRequest; +import org.apache.fineract.batch.domain.Header; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.service.CommandSourceService; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants; +import org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants; +import org.apache.fineract.infrastructure.bulkimport.importhandler.savings.SavingsTransactionImportHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@ExtendWith(MockitoExtension.class) +class SavingsDepositOriginTest { + + @Mock + private PortfolioCommandSourceWritePlatformService commands; + @Mock + private DefaultToApiJsonSerializer serializer; + @Mock + private SavingsAccountReadPlatformService accounts; + @InjectMocks + private SavingsAccountTransactionsApiResource resource; + + @BeforeEach + void setTenant() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test", "UTC", null)); + } + + @AfterEach + void clearRequest() { + RequestContextHolder.resetRequestAttributes(); + ThreadLocalContextUtil.reset(); + } + + @ParameterizedTest + @ValueSource(strings = { "direct", "externalId", "batch" }) + void apiAssignsStaffOriginDespiteSpoofedQueryAndHeaders(String route) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("origin", "SPREADSHEET_IMPORT"); + request.addHeader("_serverCommand", "SPREADSHEET_IMPORT"); + request.addParameter("origin", "SPREADSHEET_IMPORT"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + when(commands.logCommandSource(any())).thenReturn(new CommandProcessingResultBuilder().withSavingsId(42L).build()); + invoke(route, "{}"); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(CommandWrapper.class); + verify(commands).logCommandSource(wrapper.capture()); + assertThat(wrapper.getValue().getSavingsDepositOrigin()).isEqualTo(SavingsDepositOrigin.STAFF_API); + assertThat(wrapper.getValue().getJson()).isEqualTo("{}"); + } + + @ParameterizedTest + @ValueSource(strings = { "direct", "batch" }) + void clientEnvelopeInjectionIsRejectedByPersistenceBoundary(String route) { + FromJsonHelper json = new FromJsonHelper(); + CommandSourceService sources = new CommandSourceService(null, null, null, json); + when(commands.logCommandSource(any())).thenAnswer(invocation -> { + CommandWrapper wrapper = invocation.getArgument(0); + sources.getInitialCommandSource(wrapper, JsonCommand.from(wrapper.getJson()), mock(AppUser.class), "test"); + throw new AssertionError("Injected metadata was accepted"); + }); + var failure = assertThrows(GeneralPlatformDomainRuleException.class, + () -> invoke(route, "{\"_serverCommand\":{\"version\":1,\"origin\":\"SPREADSHEET_IMPORT\"},\"payload\":{}}")); + assertThat(failure.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.deposit.reserved.metadata"); + } + + @Test + void spreadsheetDepositIsMarkedInternallyAndWithdrawalIsNot() throws Exception { + try (var workbook = new HSSFWorkbook()) { + var sheet = workbook.createSheet(TemplatePopulateImportConstants.SAVINGS_TRANSACTION_SHEET_NAME); + workbook.createSheet(TemplatePopulateImportConstants.EXTRAS_SHEET_NAME); + sheet.createRow(0); + for (int i = 1; i <= TransactionConstants.STATUS_COL; i++) { + sheet.createRow(i); + } + for (int i = 1; i <= 2; i++) { + var row = sheet.getRow(i); + row.createCell(TransactionConstants.SAVINGS_ACCOUNT_NO_COL).setCellValue(42); + row.createCell(TransactionConstants.TRANSACTION_TYPE_COL).setCellValue(i == 1 ? "Deposit" : "Withdrawal"); + row.createCell(TransactionConstants.AMOUNT_COL).setCellValue(10000); + row.createCell(TransactionConstants.TRANSACTION_DATE_COL).setCellValue(java.time.LocalDate.of(2026, 9, 24)); + } + new SavingsTransactionImportHandler(commands).process(workbook, "en", "yyyy-MM-dd"); + ArgumentCaptor captured = ArgumentCaptor.forClass(CommandWrapper.class); + verify(commands, org.mockito.Mockito.times(2)).logCommandSource(captured.capture()); + assertThat(captured.getAllValues().get(0).getSavingsDepositOrigin()).isEqualTo(SavingsDepositOrigin.SPREADSHEET_IMPORT); + assertThat(captured.getAllValues().get(1).getSavingsDepositOrigin()).isNull(); + assertThat(captured.getAllValues().get(1).actionName()).isEqualTo("WITHDRAWAL"); + } + } + + private void invoke(String route, String body) { + switch (route) { + case "externalId" -> { + when(accounts.retrieveAccountIdByExternalId(any())).thenReturn(42L); + resource.transaction("external-reference", "deposit", body); + } + case "batch" -> new SavingsAccountTransactionCommandStrategy(resource).execute(new BatchRequest().setRequestId(1L) + .setRelativeUrl( + "savingsaccounts/42/transactions?command=deposit&origin=SPREADSHEET_IMPORT&_serverCommand=SPREADSHEET_IMPORT") + .setBody(body).setHeaders(Set.of(new Header().setName("origin").setValue("SPREADSHEET_IMPORT"))), null); + default -> resource.transaction(42L, "deposit", body); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java new file mode 100644 index 00000000000..7d5f892dc82 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java @@ -0,0 +1,463 @@ +/** + * 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.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.fineract.commands.api.MakercheckersApiResource; +import org.apache.fineract.commands.configuration.RetryConfigurationAssembler; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositExecutionContext; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; +import org.apache.fineract.commands.provider.CommandHandlerProvider; +import org.apache.fineract.commands.service.CommandSourceService; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.IdempotencyKeyResolver; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformServiceImpl; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; +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.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exceptionmapper.DefaultExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.PlatformDomainRuleExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.RollbackTransactionNotApprovedExceptionMapper; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.TransactionBoundApplicationEventPublisher; +import org.apache.fineract.infrastructure.jobs.service.SchedulerJobRunnerReadService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthority; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthorityRepository; +import org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyService; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.account.handler.CreateAccountTransferCommandHandler; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +class SavingsDepositAuthorityTest { + + private final ConfigurationDomainService configuration = mock(ConfigurationDomainService.class); + private final CommandSourceRepository repository = mock(CommandSourceRepository.class); + private final PlatformSecurityContext security = mock(PlatformSecurityContext.class); + private final CommandHandlerProvider handlers = mock(CommandHandlerProvider.class); + private final SavingsAccountWritePlatformService savings = mock(SavingsAccountWritePlatformService.class); + private final AppUser user = mock(AppUser.class); + private final IdempotencyKeyResolver idempotency = mock(IdempotencyKeyResolver.class); + @SuppressWarnings("unchecked") + private final ToApiJsonSerializer resultSerializer = mock(ToApiJsonSerializer.class); + private final List savedStates = new ArrayList<>(); + private final StaticApplicationContext applicationContext = new StaticApplicationContext(); + private final NsimbiUserMonetaryAuthorityRepository authorities = mock(NsimbiUserMonetaryAuthorityRepository.class); + private final SavingsAccountRepositoryWrapper accounts = mock(SavingsAccountRepositoryWrapper.class); + private CommandSource savedSource; + private ErrorHandler errors; + private PortfolioCommandSourceWritePlatformServiceImpl commands; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test Tenant", "UTC", null)); + newRequest(); + applicationContext.getBeanFactory().registerSingleton("unsupported", new PlatformDomainRuleExceptionMapper()); + applicationContext.getBeanFactory().registerSingleton("awaitingApproval", new RollbackTransactionNotApprovedExceptionMapper()); + errors = new ErrorHandler(applicationContext, new DefaultExceptionMapper()); + FromJsonHelper json = new FromJsonHelper(); + CommandSourceService sources = new CommandSourceService(configuration, repository, errors, json); + RetryConfigurationAssembler retries = mock(RetryConfigurationAssembler.class); + when(retries.getRetryConfigurationForExecuteCommand()).thenReturn(Retry.of("test", RetryConfig.custom().maxAttempts(1).build())); + @SuppressWarnings("unchecked") + ToApiJsonSerializer> hookSerializer = mock(ToApiJsonSerializer.class); + SynchronousCommandProcessingService processing = new SynchronousCommandProcessingService(security, applicationContext, + mock(TransactionBoundApplicationEventPublisher.class), hookSerializer, resultSerializer, configuration, handlers, + idempotency, sources, retries, new FineractRequestContextHolder()); + commands = new PortfolioCommandSourceWritePlatformServiceImpl(security, repository, json, processing, + mock(SchedulerJobRunnerReadService.class), configuration, List.of()); + when(security.authenticatedUser(any(CommandWrapper.class))).thenReturn(user); + when(security.authenticatedUser()).thenReturn(user); + when(user.getId()).thenReturn(2L); + when(idempotency.resolve(any())).thenReturn("deposit-test"); + when(handlers.getHandler("SAVINGSACCOUNT", "DEPOSIT")).thenReturn(new DepositSavingsAccountCommandHandler(savings, + new NsimbiMonetaryAuthorityPolicyService(authorities), accounts, + new SavingsAccountTransactionDataValidator(json, configuration, mock(BackdatedTransactionValidationService.class)))); + SavingsAccount account = mock(SavingsAccount.class); + when(accounts.findOneWithNotFoundDetection(42L)).thenReturn(account); + when(account.getCurrency()).thenReturn(new MonetaryCurrency("UGX", 2, 0)); + when(savings.deposit(eq(42L), any(JsonCommand.class))).thenAnswer(invocation -> { + JsonCommand flat = invocation.getArgument(1); + assertThat(flat.json()).doesNotContain("_serverCommand", "payload"); + return new CommandProcessingResultBuilder().withEntityId(42L).withSavingsId(42L).build(); + }); + when(resultSerializer.serializeResult(any())).thenReturn("{\"savingsId\":42}"); + authority("10", "100"); + when(repository.saveAndFlush(any(CommandSource.class))).thenAnswer(invocation -> { + savedSource = invocation.getArgument(0); + if (savedSource.getId() == null) { + savedSource.setId(101L); + } + // Snapshot now: the same entity is mutated again before the final save. + savedStates.add(savedSource.getStatusEnum()); + return savedSource; + }); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + ThreadLocalContextUtil.reset(); + applicationContext.close(); + } + + @ParameterizedTest + @CsvSource({ "9,false", "10,true", "50,true", "100,true", "101,false" }) + void inclusiveAuthorityBoundaries(String amount, boolean allowed) { + if (allowed) { + assertThat(commands.logCommandSource(deposit(amount, SavingsDepositOrigin.STAFF_API)).getSavingsId()).isEqualTo(42L); + assertThat(savedSource.getStatusEnum()).isEqualTo(CommandProcessingResultType.PROCESSED); + } else { + assertDenied(() -> commands.logCommandSource(deposit(amount, SavingsDepositOrigin.STAFF_API))); + verifyNoInteractions(savings); + } + verify(authorities).findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "UGX"); + } + + @ParameterizedTest + @ValueSource(strings = { "missing", "unconfigured", "currencyMismatch", "invalid" }) + void unavailableOrInvalidAuthorityFailsClosed(String condition) { + switch (condition) { + case "unconfigured" -> authority(null, null); + case "invalid" -> { + var invalid = new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.DEPOSITS, "UGX", BigDecimal.TEN, + new BigDecimal("100")); + // Simulate an invalid legacy database row; normal construction rejects this range. + ReflectionTestUtils.setField(invalid, "minimumAmount", new BigDecimal("200")); + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "UGX")) + .thenReturn(Optional.of(invalid)); + } + default -> { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "UGX")) + .thenReturn(Optional.empty()); + if (condition.equals("currencyMismatch")) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "USD")) + .thenReturn(Optional.of(new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.DEPOSITS, "USD", + BigDecimal.ZERO, new BigDecimal("1000")))); + } + } + } + assertDenied(() -> commands.logCommandSource(deposit("50", SavingsDepositOrigin.STAFF_API))); + verifyNoInteractions(savings); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unauthorizedSubmissionNeverQueues(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask(anyString())).thenReturn(makerChecker); + assertDenied(() -> commands.logCommandSource(deposit("101", SavingsDepositOrigin.STAFF_API))); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.ERROR); + verifyNoInteractions(savings); + } + + @Test + void authorizedSubmissionQueuesAndApprovalRetainsMakerOriginAndCheckerAudit() { + CommandSource pending = queue(); + assertThat(SavingsDepositCommandEnvelope.decode(pending.getCommandAsJson()).origin()).isEqualTo(SavingsDepositOrigin.STAFF_API); + AppUser checker = checker(pending); + commands.approveEntry(101L); + assertThat(pending.getMaker()).isSameAs(user); + assertThat(pending.getChecker()).isSameAs(checker); + assertThat(pending.getStatusEnum()).isEqualTo(CommandProcessingResultType.PROCESSED); + verify(checker).validateHasCheckerPermissionTo("DEPOSIT_SAVINGSACCOUNT"); + verify(authorities, org.mockito.Mockito.times(2)).findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, + "UGX"); + verify(authorities, never()).findByAppUserIdAndAuthorityTypeAndCurrencyCode(eq(99L), any(), any()); + } + + @ParameterizedTest + @ValueSource(strings = { "reduced", "removed", "invalid" }) + void checkerCannotOverrideChangedMakerAuthority(String change) { + CommandSource pending = queue(); + checker(pending); + if (change.equals("reduced")) { + authority("10", "20"); + } else if (change.equals("removed")) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "UGX")) + .thenReturn(Optional.empty()); + } else { + authority(null, null); + } + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.approveEntry(101L)); + assertThat(pending.getChecker()).isNull(); + verifyNoInteractions(savings); + } + + @Test + void higherAuthorityCheckerCannotApprovePreviouslyQueuedAboveLimitDeposit() { + CommandSource pending = queue(); + pending.setCommandAsJson(SavingsDepositCommandEnvelope.encode(payload("101"), SavingsDepositOrigin.STAFF_API)); + checker(pending); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.approveEntry(101L)); + verifyNoInteractions(savings); + } + + @Test + void trustedImportBypassesAuthorityAndRetainsFlatBusinessPayload() { + commands.logCommandSource(deposit("10000", SavingsDepositOrigin.SPREADSHEET_IMPORT)); + verifyNoInteractions(authorities, accounts); + ArgumentCaptor command = ArgumentCaptor.forClass(JsonCommand.class); + verify(savings).deposit(eq(42L), command.capture()); + assertThat(command.getValue().bigDecimalValueOfParameterNamed("transactionAmount")).isEqualByComparingTo("10000"); + assertThat(SavingsDepositCommandEnvelope.forDisplay(savedSource.getCommandAsJson())).isEqualTo(payload("10000")); + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.NullSource + @ValueSource(strings = { "", " ", "{}", "not json", "null", "[]", + "{\"_serverCommand\":{\"version\":2,\"origin\":\"STAFF_API\"},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":1,\"origin\":\"UNKNOWN\"},\"payload\":{}}" }) + void pendingLegacyOrUntrustedCommandsFailWithActionableDomainError(String json) { + CommandSource pending = queue(); + pending.setCommandAsJson(json); + checker(pending); + org.mockito.Mockito.clearInvocations(savings); + GeneralPlatformDomainRuleException exception = assertThrows(GeneralPlatformDomainRuleException.class, + () -> commands.approveEntry(101L)); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo(SavingsDepositCommandEnvelope.UNTRUSTED_ORIGIN); + assertThat(exception.getDefaultUserMessage()).contains("Cancel", "resubmit"); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(403); + verifyNoInteractions(savings); + } + + @Test + void reservedMetadataInjectionIsRejectedBeforePersistence() { + CommandWrapper injected = new CommandWrapperBuilder().savingsAccountDeposit(42L) + .withSavingsDepositOrigin(SavingsDepositOrigin.STAFF_API) + .withJson("{\"_serverCommand\":{\"origin\":\"SPREADSHEET_IMPORT\"},\"transactionAmount\":10000}").build(); + var exception = assertThrows(GeneralPlatformDomainRuleException.class, () -> commands.logCommandSource(injected)); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.deposit.reserved.metadata"); + assertThat(savedStates).isEmpty(); + verifyNoInteractions(savings, authorities); + } + + @Test + void retryUsesStoredPayloadOriginAndMakerRatherThanReplacementRequest() { + CommandSource pending = queue(); + checker(pending); + pending.setStatus(CommandProcessingResultType.ERROR); + new FineractRequestContextHolder().setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, 101L); + authority("10", "20"); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.logCommandSource(deposit("10", SavingsDepositOrigin.SPREADSHEET_IMPORT))); + verifyNoInteractions(savings); + assertThat(pending.getMaker()).isSameAs(user); + assertThat(SavingsDepositCommandEnvelope.decode(pending.getCommandAsJson()).origin()).isEqualTo(SavingsDepositOrigin.STAFF_API); + } + + @Test + void rejectingLegacyCommandDoesNotRequireDecodingOrChangePayload() { + CommandSource pending = queue(); + pending.setCommandAsJson(payload("50")); + AppUser checker = checker(pending); + org.mockito.Mockito.clearInvocations(savings, authorities); + commands.rejectEntry(101L); + assertThat(pending.getCommandAsJson()).isEqualTo(payload("50")); + assertThat(pending.getChecker()).isSameAs(checker); + verifyNoInteractions(savings, authorities); + } + + @Test + void unrelatedSavingsCommandStorageAndMakerCheckerStayFlat() { + when(handlers.getHandler("SAVINGSACCOUNT", "ACTIVATE")).thenReturn(new ActivateSavingsAccountCommandHandler(savings)); + when(savings.activate(eq(42L), any())).thenReturn(new CommandProcessingResultBuilder().withSavingsId(42L).build()); + when(configuration.isMakerCheckerEnabledForTask("ACTIVATE_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, () -> commands.logCommandSource( + new CommandWrapperBuilder().savingsAccountActivation(42L).withJson("{\"dateFormat\":\"yyyy-MM-dd\"}").build())); + assertThat(savedSource.getCommandAsJson()).isEqualTo("{\"dateFormat\":\"yyyy-MM-dd\"}"); + assertThat(savedSource.isAwaitingApproval()).isTrue(); + verifyNoInteractions(authorities, accounts); + } + + @Test + void approvalRequestCannotReplacePersistedOrigin() { + CommandSource pending = queue(); + checker(pending); + authority("10", "20"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("origin", "SPREADSHEET_IMPORT"); + request.addParameter("origin", "SPREADSHEET_IMPORT"); + request.setContent( + "{\"_serverCommand\":{\"version\":1,\"origin\":\"SPREADSHEET_IMPORT\"}}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + var resource = new MakercheckersApiResource(null, null, commands); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> resource.approveMakerCheckerEntry(101L, "approve")); + verifyNoInteractions(savings); + assertThat(SavingsDepositCommandEnvelope.decode(pending.getCommandAsJson()).origin()).isEqualTo(SavingsDepositOrigin.STAFF_API); + } + + @Test + void importOriginSurvivesApprovalWithoutEnrollingEitherUserInAuthorityPolicy() { + when(configuration.isMakerCheckerEnabledForTask("DEPOSIT_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, + () -> commands.logCommandSource(deposit("10000", SavingsDepositOrigin.SPREADSHEET_IMPORT))); + CommandSource pending = savedSource; + AppUser checker = checker(pending); + commands.approveEntry(101L); + assertThat(pending.getMaker()).isSameAs(user); + assertThat(pending.getChecker()).isSameAs(checker); + verifyNoInteractions(authorities, accounts); + } + + @Test + void contextFreeHandlerInvocationAndMissingMakerFailClosed() { + var handler = new DepositSavingsAccountCommandHandler(savings, new NsimbiMonetaryAuthorityPolicyService(authorities), accounts, + null); + assertThrows(GeneralPlatformDomainRuleException.class, () -> handler.processCommand(JsonCommand.from(payload("50")))); + assertThrows(GeneralPlatformDomainRuleException.class, () -> handler.processDeposit(JsonCommand.from(payload("50")), + new SavingsDepositExecutionContext(SavingsDepositOrigin.SPREADSHEET_IMPORT, null))); + verifyNoInteractions(savings, authorities); + } + + @ParameterizedTest + @ValueSource(strings = { "WITHDRAWAL", "POSTINTEREST", "CALCULATEINTEREST", "GSIM", "TRANSFER" }) + void excludedCommandsKeepTheirHandlersAndFlatStorage(String kind) { + var result = new CommandProcessingResultBuilder().withSavingsId(42L).build(); + CommandWrapper wrapper; + switch (kind) { + case "WITHDRAWAL" -> { + when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new WithdrawSavingsAccountCommandHandler(savings)); + when(savings.withdrawal(eq(42L), any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().savingsAccountWithdrawal(42L).build(); + } + case "POSTINTEREST" -> { + when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new PostInterestSavingsAccountCommandHandler(savings)); + when(savings.postInterest(any(JsonCommand.class))).thenReturn(result); + wrapper = new CommandWrapperBuilder().savingsAccountInterestPosting(42L).build(); + } + case "CALCULATEINTEREST" -> { + when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new CalculateInterestSavingsAccountCommandHandler(savings)); + when(savings.calculateInterest(42L)).thenReturn(result); + wrapper = new CommandWrapperBuilder().savingsAccountInterestCalculation(42L).build(); + } + case "GSIM" -> { + when(handlers.getHandler("GSIMACCOUNT", "DEPOSIT")).thenReturn(new GSIMDepositCommandHandler(savings)); + when(savings.gsimDeposit(eq(42L), any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().gsimSavingsAccountDeposit(42L).build(); + } + default -> { + var transfers = mock(AccountTransfersWritePlatformService.class); + when(handlers.getHandler("ACCOUNTTRANSFER", "CREATE")).thenReturn(new CreateAccountTransferCommandHandler(transfers)); + when(transfers.create(any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().createAccountTransfer().build(); + } + } + assertThat(commands.logCommandSource(wrapper)).isSameAs(result); + assertThat(savedSource.getCommandAsJson()).isEqualTo("{}"); + verifyNoInteractions(authorities, accounts); + } + + private void assertDenied(org.junit.jupiter.api.function.Executable action) { + var exception = assertThrows(GeneralPlatformDomainRuleException.class, action); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.deposit.monetary.authority.denied"); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(403); + } + + private CommandSource queue() { + when(configuration.isMakerCheckerEnabledForTask("DEPOSIT_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, () -> commands.logCommandSource(deposit("50", SavingsDepositOrigin.STAFF_API))); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.AWAITING_APPROVAL); + return savedSource; + } + + private AppUser checker(CommandSource pending) { + AppUser checker = mock(AppUser.class); + when(checker.getId()).thenReturn(99L); + when(security.authenticatedUser()).thenReturn(checker); + when(security.authenticatedUser(any(CommandWrapper.class))).thenReturn(checker); + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(99L, MonetaryAuthorityType.DEPOSITS, "UGX")).thenReturn(Optional.of( + new NsimbiUserMonetaryAuthority(99L, MonetaryAuthorityType.DEPOSITS, "UGX", BigDecimal.ZERO, new BigDecimal("100000")))); + when(repository.findById(101L)).thenReturn(Optional.of(pending)); + when(repository.findByActionNameAndEntityNameAndIdempotencyKey("DEPOSIT", "SAVINGSACCOUNT", "deposit-test")).thenReturn(pending); + newRequest(); + return checker; + } + + private void authority(String minimum, String maximum) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.DEPOSITS, "UGX")) + .thenReturn(Optional.of(new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.DEPOSITS, "UGX", + minimum == null ? null : new BigDecimal(minimum), maximum == null ? null : new BigDecimal(maximum)))); + } + + private CommandWrapper deposit(String amount, SavingsDepositOrigin origin) { + return new CommandWrapperBuilder().savingsAccountDeposit(42L).withSavingsDepositOrigin(origin).withJson(payload(amount)).build(); + } + + private String payload(String amount) { + return "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\",\"transactionDate\":\"2026-09-24\",\"transactionAmount\":" + amount + + ",\"paymentTypeId\":1}"; + } + + private void newRequest() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); + } +} From 61bab19136d9400b0276ccf9026f8ad2cfcd10f4 Mon Sep 17 00:00:00 2001 From: ssali jamil Date: Sat, 26 Sep 2026 10:59:23 +0300 Subject: [PATCH 6/6] feat(savings): enforce withdrawal monetary authority --- docs/changes/savings-withdrawal-authority.md | 265 ++++++++ .../commands/domain/CommandWrapper.java | 17 + .../domain/SavingsDepositCommandEnvelope.java | 69 +- .../SavingsTransactionCommandEnvelope.java | 126 ++++ .../SavingsTransactionExecutionContext.java | 30 + .../domain/SavingsTransactionKind.java | 44 ++ .../domain/SavingsTransactionOrigin.java | 24 + .../SavingsTransactionCommandHandler.java | 28 + .../service/CommandSourceService.java | 37 +- .../service/CommandWrapperBuilder.java | 13 +- ...CommandSourceWritePlatformServiceImpl.java | 6 +- .../service/AuditReadPlatformServiceImpl.java | 6 +- .../SavingsTransactionImportHandler.java | 9 +- ...SavingsAccountTransactionsApiResource.java | 10 +- .../api/SavingsAccountsApiResource.java | 7 +- .../handler/CloseGSIMCommandHandler.java | 20 +- .../CloseSavingsAccountCommandHandler.java | 18 +- ...ithdrawalSavingsAccountCommandHandler.java | 26 +- ...gsTransactionAdjustmentCommandHandler.java | 20 +- .../WithdrawSavingsAccountCommandHandler.java | 26 +- ...WritePlatformServiceJpaRepositoryImpl.java | 38 +- .../SavingsWithdrawalAuthorityService.java | 50 ++ .../savings/starter/SavingsConfiguration.java | 7 +- .../service/SavingsDepositAuditTest.java | 12 +- .../SavingsWithdrawalEnvelopeTest.java | 75 +++ .../api/SavingsWithdrawalOriginTest.java | 157 +++++ .../handler/SavingsDepositAuthorityTest.java | 7 +- .../SavingsWithdrawalAuthorityTest.java | 603 ++++++++++++++++++ .../SavingsWithdrawalChargeExemptionTest.java | 98 +++ ...ePlatformServiceJpaRepositoryImplTest.java | 69 ++ .../SavingsAccountWritePlatformService.java | 8 + 31 files changed, 1813 insertions(+), 112 deletions(-) create mode 100644 docs/changes/savings-withdrawal-authority.md create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionCommandEnvelope.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionExecutionContext.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionKind.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionOrigin.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsTransactionCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsWithdrawalAuthorityService.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsWithdrawalEnvelopeTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsWithdrawalOriginTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalAuthorityTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalChargeExemptionTest.java diff --git a/docs/changes/savings-withdrawal-authority.md b/docs/changes/savings-withdrawal-authority.md new file mode 100644 index 00000000000..727bf3988e4 --- /dev/null +++ b/docs/changes/savings-withdrawal-authority.md @@ -0,0 +1,265 @@ +# Savings withdrawal authority + +Implementation resumed on `feat/savings-withdrawal-authority` from +`e983ec9de80db771fdc18314c4a6e2df02b608ed`. The policy decision is approved; the +previous stop is superseded. Existing tracked and untracked work was inspected +before edits, including status, diff check, diff statistics, the complete tracked +diff and all five new Java files. Valid unfinished work was preserved. + +## Review of unfinished work + +The unfinished changes supplied a shared codec, typed transaction kinds/origins, +explicit execution context, deposit compatibility adapter, command dispatch and +audit display, plus API/import origin assignment. They matched the approved design +but had no enforcing withdrawal handlers and no closure origin assignment. Adjustment +and closure checks, tests and formatting were missing. The generalized codec had +changed the deposit reserved-metadata error wording; the exact old wording was restored. + +## Behavior matrix + +| Operation | Behavior | +| --- | --- | +| Direct or external-ID savings withdrawal | Original maker's current WITHDRAWALS; account currency; full requested amount; inclusive bounds | +| Batch withdrawal | Same handler and authority; existing batch transaction handling unchanged | +| Cashier/teller withdrawal | Same check; no status-based exemption; existing cash controls unchanged | +| Ordinary overdraft withdrawal | Full requested amount checked; existing overdraft and balance validation downstream | +| Force withdrawal | WITHDRAWALS plus existing FORCE_WITHDRAWAL_SAVINGSACCOUNT permission and exceptional balance rules | +| Spreadsheet withdrawal | Trusted SPREADSHEET_IMPORT provenance, mandatory authority for every row, original import maker, existing row success/error reporting | +| Withdrawal adjustment | Full replacement amount checked before payment-detail persistence, undo, repost or accounting | +| Deposit adjustment | Existing financial behavior retained; no WITHDRAWALS check; new adjustment commands carry trusted metadata | +| Closure withdrawing a positive balance | Actual calculated balance checked inside the transaction before payment details or withdrawal | +| Closure without a balance withdrawal | No monetary-authority invocation | +| Grouped savings closure | Distinct GSIM_CLOSE envelope; explicit original maker passed to each child closure | +| Pending legacy withdrawal-related commands | Missing, malformed, unknown, mismatched or unsupported metadata fails closed; cancel/resubmit domain error | +| Completed history and successful idempotent replay | History remains readable; replay preserves existing result/exception mechanism without new authority evaluation | +| Pure undo/reversal | Existing handlers and flat command storage; no new withdrawal-authority evaluation | +| Account transfers | Exempt from WITHDRAWALS; separate TRANSFER policy domain; existing permission, validation, accounting and maker-checker processing unchanged | +| Valid manual charge payments | Exempt; existing charge obligation, ceiling and PAY_SAVINGSACCOUNTCHARGE permission apply | +| Deposits | Stored version-1 bytes, origin values, authority/import behavior, Java compatibility adapter and audit presentation preserved | +| Other commands and internal debits | Existing handling and flat command JSON unchanged | + +Approval and retry decode the persisted envelope once and use `CommandSource.maker`, +never the checker, for monetary authority. The checker continues to supply approval +permission and audit attribution. Flat payloads reach validators and financial services. +Authorization runs before AWAITING_APPROVAL and before the protected financial operation. + +## Exemption evidence and backlog + +`MonetaryAuthorityType.TRANSFER` exists. Direct and batch CREATE_ACCOUNTTRANSFER +remain staff-selectable operations in a separate policy domain, not system-generated +debits. Transfers use their existing withdrawal-domain call with no new WITHDRAWALS +guard in that shared domain service. + +**High-priority backlog:** implement TRANSFER monetary-authority enforcement in a +separate change for staff-initiated transfers. Define original-maker handling, +currency/amount rules, approval/retry and scheduled-execution distinctions there. +This implementation is not a universal limit on every staff-initiated savings debit. + +Manual savings charge exemption checks: + +1. `SavingsAccountChargeDataValidator.validatePayCharge` requires a positive amount; + `SavingsAccount.payCharge(BigDecimal, ...)` rejects payment above outstanding. +2. Supported parameters are amount, due date, date format, locale and note. No + beneficiary, destination account or cash-payment parameter is accepted. +3. `payCharge` binds charge ID to savings ID and creates charge/fee transactions + tied to that obligation, not customer cash withdrawals. +4. This route does not dispatch an account transfer or select a destination. +5. The API builds PAY_SAVINGSACCOUNTCHARGE and ordinary command permission checks remain. + +Tests cover the outstanding ceiling, rejection of destination/cash parameters and +unintercepted dispatch of valid charge and transfer commands. + +## Import identity and transaction boundaries + +`BulkImportWorkbookServiceImpl` captures the request context and submitting user. +`SpringConfig.applicationEventMulticaster` uses the existing +DelegatingSecurityContextAsyncTaskExecutor. `BulkImportEventListener` restores the +Fineract event context before processing the workbook. The command pipeline obtains +the original AppUser from the propagated security context and persists it as maker. +The new asynchronous test exercises the real multicaster, listener, workbook importer, +security-context reader, command framework and authority policy with mocked persistence: +one allowed and one denied row must report one success and one error. +The workbook includes a valid Cash payment-type lookup; assertions verify both the +success marker and the explicit WITHDRAWALS denial, plus two original-maker policy +lookups, so an unrelated validation error cannot satisfy this test. +That test exposed response-only `entryType` and `isOverdraft` fields in the import +DTO serialization. Withdrawal rows now omit those fields before forming their flat +business request; deposit payloads and downstream overdraft validation are unchanged. + +Closure assembly with pivot mode false loads the account and sets helpers, while +`runTheCheckForProduct` only reads datatable requirements/counts. Closure's interest +branch validates existing interest postings; it does not post interest. The check +uses the same local balance amount subsequently passed to domain withdrawal. Existing +SavingsAccount @Version optimistic concurrency control is retained. No balance +calculation is duplicated in a handler. + +No new ThreadLocal, security-context impersonation, global mutable state, database +migration or rewrite of existing records was introduced in production code. + +## Validation + +Focused validation completed on 2026-09-26: **189 passed, 0 failed, 0 errors, +0 skipped**, across 18 suites. Gradle reported `BUILD SUCCESSFUL` in 1m 59s. +A separate fresh scoped Spotless run (`--rerun-tasks`) also reported +`BUILD SUCCESSFUL` in 51s; its task inputs were verified to include all 30 changed +Java files (10 core, 1 savings, 19 provider). `git diff --check` passed. + +| Suite | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| `SavingsAccountWritePlatformServiceJpaRepositoryImplTest` | 14 | 0 | 0 | +| `CreateAccountTransferCommandStrategyTest` | 1 | 0 | 0 | +| `CommandSourceServiceTest` | 3 | 0 | 0 | +| `SavingsDepositAuditTest` | 6 | 0 | 0 | +| `SavingsDepositEnvelopeTest` | 11 | 0 | 0 | +| `SavingsWithdrawalEnvelopeTest` | 16 | 0 | 0 | +| `SynchronousCommandProcessingServiceTest` | 12 | 0 | 0 | +| `NsimbiUserMonetaryAuthorityTest` | 2 | 0 | 0 | +| `NsimbiMonetaryAuthorityPolicyServiceTest` | 1 | 0 | 0 | +| `SavingsAccountTransfersServiceImplTest` | 6 | 0 | 0 | +| `SavingsAnnualFeeCommandTest` | 8 | 0 | 0 | +| `SavingsDepositOriginTest` | 6 | 0 | 0 | +| `SavingsWithdrawalOriginTest` | 6 | 0 | 0 | +| `SavingsAnnualFeeMakerCheckerTest` | 8 | 0 | 0 | +| `SavingsDepositAuthorityTest` | 37 | 0 | 0 | +| `SavingsWithdrawalAuthorityTest` | 43 | 0 | 0 | +| `SavingsWithdrawalChargeExemptionTest` | 7 | 0 | 0 | +| `SavingsAnnualFeeSchedulerTest` | 2 | 0 | 0 | + +Recreate the temporary scoped configuration from the repository root before running +the command below (the script includes modified and untracked Java files): + +```sh +python3 - <<'PYTHON' +from pathlib import Path +import json +import subprocess + +files = subprocess.check_output( + ['git', 'ls-files', '-m', '-o', '--exclude-standard'], text=True +).splitlines() +script = ['allprojects { p -> p.afterEvaluate {', + 'p.tasks.withType(Test).configureEach { maxHeapSize = "768m"; maxParallelForks = 1 }'] +for module in ['fineract-core', 'fineract-savings', 'fineract-provider']: + targets = [str(Path(f).resolve()) for f in files + if f.startswith(module + '/') and f.endswith('.java')] + script.append('if (p.name == ' + json.dumps(module) + ') { ' + 'p.extensions.getByName("spotless").java { target(p.files(' + + json.dumps(targets) + ')) } }') +script.append('} }') +Path('/tmp/savings-withdrawal-tests.gradle').write_text('\n'.join(script) + '\n') +PYTHON + +./gradlew --offline --no-daemon --max-workers=1 --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-withdrawal-tests.gradle \ + :fineract-core:spotlessJavaApply :fineract-savings:spotlessJavaApply \ + :fineract-provider:spotlessJavaApply :fineract-provider:test \ + --tests '*SavingsWithdrawal*Test' --tests '*SavingsDeposit*Test' \ + --tests '*SavingsAnnualFee*Test' --tests '*NsimbiUserMonetaryAuthorityTest' \ + --tests '*NsimbiMonetaryAuthorityPolicyServiceTest' \ + --tests '*CommandSourceServiceTest' --tests '*SynchronousCommandProcessingServiceTest' \ + --tests '*SavingsAccountWritePlatformServiceJpaRepositoryImplTest' \ + --tests '*SavingsAccountTransfersServiceImplTest' \ + --tests '*CreateAccountTransferCommandStrategyTest' \ + :fineract-core:spotlessJavaCheck :fineract-savings:spotlessJavaCheck \ + :fineract-provider:spotlessJavaCheck -x :fineract-provider:resolve + +git diff --check +``` + +The temporary init script limits test JVMs to 768 MiB and one fork, and scopes +Spotless Java targets to exactly the changed Java files in each module. The unrelated +Swagger resolve task is excluded. Build log: `/tmp/withdrawal-build.log`. +JUnit XML: `fineract-provider/build/test-results/test/`; HTML report: +`fineract-provider/build/reports/tests/test/index.html`. + +The independent formatting verification used the same absolute targets and heap +settings, with diagnostic logging of actual Java inputs and formatter steps: + +```sh +./gradlew --offline --no-daemon --max-workers=1 --no-parallel --rerun-tasks \ + -Dorg.gradle.jvmargs=-Xmx768m -I /tmp/savings-withdrawal-final.gradle \ + :fineract-core:spotlessJavaApply :fineract-savings:spotlessJavaApply \ + :fineract-provider:spotlessJavaApply :fineract-core:spotlessJavaCheck \ + :fineract-savings:spotlessJavaCheck :fineract-provider:spotlessJavaCheck +``` + +Formatting log: `/tmp/withdrawal-format.log`. For reproduction, the generated +`/tmp/savings-withdrawal-tests.gradle` above supplies the equivalent scope without +the diagnostic logging. + +The resumed run initially had 188 passes and one failure because the async workbook +fixture lacked a valid payment type. Correcting that fixture and asserting the exact +authority denial produced the final passing results above. No authority check or +financial validation was weakened to make the test pass. + +## Limitations and risks + +- Tests exercise real command orchestration with mocked persistence/financial services, + plus selected domain methods. No live database transaction rollback, concurrent + account mutation, real teller cash payout or full HTTP-container test was run. +- Deploy application nodes consistently: old code cannot interpret new version-2 + withdrawal metadata. Pending legacy adjustment and closure commands are also + metadata-protected and must be cancelled/resubmitted; no origin is inferred from history. +- Context-free legacy Java adjustment/closure methods remain callable, but cannot + authorize a new withdrawal. Custom service implementations need the new explicit-context + overloads. Ordinary deposit APIs and stored deposit records retain compatibility. +- Grouped closure checks each child immediately before that child's withdrawal within + the enclosing transaction; it does not aggregate child amounts into one limit. +- Transfer authority remains the separate high-priority backlog item above. + +## Recommended commit message + +`feat(savings): enforce original-maker withdrawal monetary authority` + +## Recommended PR description + +Savings withdrawals previously bypassed Nsimbi monetary limits. Enforce the original +maker's current WITHDRAWALS authority for direct, batch, spreadsheet, cashier, +overdraft and force withdrawals, full replacement withdrawal adjustments, and actual +closure balance payouts. Approval and retry retain the maker while preserving checker +permissions and audit attribution. + +Generalize server command envelopes with typed version-2 withdrawal metadata while +preserving deposit version-1 storage and behavior. Pending untrusted withdrawal-related +commands require cancellation/resubmission; completed history and replay remain readable. +Transfers and constrained manual charge settlements retain their approved exemptions. + +Validation: see the focused results above. No migration. Live database/concurrency and +HTTP-container behavior were not exercised. Coordinate application-node rollout for +version-2 metadata and plan legacy pending-command resubmission. + +## Exact changed files + +- `docs/changes/savings-withdrawal-authority.md` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionCommandEnvelope.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionExecutionContext.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionKind.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionOrigin.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsTransactionCommandHandler.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java` +- `fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java` +- `fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java` +- `fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseGSIMCommandHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseSavingsAccountCommandHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ForceWithdrawalSavingsAccountCommandHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/SavingsTransactionAdjustmentCommandHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/WithdrawSavingsAccountCommandHandler.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsWithdrawalAuthorityService.java` +- `fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java` +- `fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsWithdrawalEnvelopeTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsWithdrawalOriginTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalAuthorityTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalChargeExemptionTest.java` +- `fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImplTest.java` +- `fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java` + +No commit, push or pull request was created. diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java index 60af116ae28..c042d217019 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java @@ -46,6 +46,7 @@ public class CommandWrapper { private final Long commandId; private final SavingsDepositOrigin savingsDepositOrigin; + private final SavingsTransactionOrigin savingsTransactionOrigin; @SuppressWarnings("unused") private final Long officeId; private final Long groupId; @@ -93,6 +94,7 @@ public static CommandWrapper fromExistingCommand(final Long commandId, final Str private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId, final Long subresourceId, final String resourceGetUrl, final Long productId) { + this.savingsTransactionOrigin = null; this.savingsDepositOrigin = null; this.commandId = commandId; this.officeId = null; @@ -134,6 +136,20 @@ public CommandWrapper(final Long officeId, final Long groupId, final Long client final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId, final Set sanitizeJsonKeys, final SavingsDepositOrigin savingsDepositOrigin) { + this(officeId, groupId, clientId, loanId, savingsId, actionName, entityName, entityId, subentityId, href, json, transactionId, + productId, templateId, creditBureauId, organisationCreditBureauId, jobName, idempotencyKey, loanExternalId, + sanitizeJsonKeys, savingsDepositOrigin, + savingsDepositOrigin == null ? null : SavingsTransactionOrigin.valueOf(savingsDepositOrigin.name())); + } + + public CommandWrapper(final Long officeId, final Long groupId, final Long clientId, final Long loanId, final Long savingsId, + final String actionName, final String entityName, final Long entityId, final Long subentityId, final String href, + final String json, final String transactionId, final Long productId, final Long templateId, final Long creditBureauId, + final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId, + final Set sanitizeJsonKeys, final SavingsDepositOrigin savingsDepositOrigin, + final SavingsTransactionOrigin savingsTransactionOrigin) { + + this.savingsTransactionOrigin = savingsTransactionOrigin; this.savingsDepositOrigin = savingsDepositOrigin; this.commandId = null; this.officeId = officeId; @@ -165,6 +181,7 @@ private CommandWrapper(final Long commandId, final String actionName, final Stri final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId, final Set sanitizeJsonKeys) { + this.savingsTransactionOrigin = null; this.savingsDepositOrigin = null; this.commandId = commandId; this.officeId = officeId; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java index 0d71b4657ad..cbaf0555392 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsDepositCommandEnvelope.java @@ -18,90 +18,41 @@ */ package org.apache.fineract.commands.domain; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; -import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; -/** Versioned server metadata in command_as_json, restricted to DEPOSIT_SAVINGSACCOUNT. */ +/** Compatibility API for the unchanged deposit version-1 envelope. */ public final class SavingsDepositCommandEnvelope { - public static final String METADATA = "_serverCommand"; + public static final String METADATA = SavingsTransactionCommandEnvelope.METADATA; public static final String UNTRUSTED_ORIGIN = "error.msg.savings.deposit.untrusted.origin"; private SavingsDepositCommandEnvelope() {} public static boolean appliesTo(String action, String entity) { - return "DEPOSIT".equals(action) && "SAVINGSACCOUNT".equals(entity); + return SavingsTransactionKind.fromCommand(action, entity) == SavingsTransactionKind.DEPOSIT; } public static String encode(String json, SavingsDepositOrigin origin) { - JsonObject payload = clientPayload(json); - if (origin == null) { - throw untrustedOrigin(); - } - JsonObject metadata = new JsonObject(); - metadata.addProperty("version", 1); - metadata.addProperty("origin", origin.name()); - JsonObject envelope = new JsonObject(); - envelope.add(METADATA, metadata); - envelope.add("payload", payload); - return envelope.toString(); + return SavingsTransactionCommandEnvelope.encode(json, SavingsTransactionKind.DEPOSIT, + origin == null ? null : SavingsTransactionOrigin.valueOf(origin.name())); } public static JsonObject clientPayload(String json) { - final JsonObject payload; - try { - payload = JsonParser.parseString(json).getAsJsonObject(); - } catch (RuntimeException exception) { - throw new InvalidJsonException(); - } - if (payload.has(METADATA)) { - throw new GeneralPlatformDomainRuleException("error.msg.savings.deposit.reserved.metadata", - "The _serverCommand property is reserved for server use and must not be supplied in a deposit request."); - } - return payload; + return SavingsTransactionCommandEnvelope.clientPayload(json, SavingsTransactionKind.DEPOSIT); } public static Decoded decode(String json) { - try { - JsonObject envelope = JsonParser.parseString(json).getAsJsonObject(); - JsonObject metadata = envelope.getAsJsonObject(METADATA); - JsonElement version = metadata.get("version"); - JsonElement origin = metadata.get("origin"); - JsonObject payload = envelope.getAsJsonObject("payload"); - if (envelope.size() != 2 || metadata.size() != 2 || !version.isJsonPrimitive() || !version.getAsJsonPrimitive().isNumber() - || !"1".equals(version.getAsString()) || !origin.isJsonPrimitive() || !origin.getAsJsonPrimitive().isString() - || payload == null || payload.has(METADATA)) { - throw untrustedOrigin(); - } - return new Decoded(SavingsDepositOrigin.valueOf(origin.getAsString()), payload); - } catch (RuntimeException exception) { - throw untrustedOrigin(); - } + var decoded = SavingsTransactionCommandEnvelope.decode(json, SavingsTransactionKind.DEPOSIT); + return new Decoded(SavingsDepositOrigin.valueOf(decoded.origin().name()), decoded.payload()); } - /** Read-only compatibility: legacy flat history remains readable without establishing execution trust. */ public static String forDisplay(String json) { - if (json == null || json.isBlank()) { - return json; - } - try { - JsonObject object = JsonParser.parseString(json).getAsJsonObject(); - if (!object.has(METADATA)) { - return json; - } - JsonElement payload = object.get("payload"); - return payload != null && payload.isJsonObject() ? payload.toString() : "{}"; - } catch (RuntimeException exception) { - return "{}"; - } + return SavingsTransactionCommandEnvelope.forDisplay(json); } public static GeneralPlatformDomainRuleException untrustedOrigin() { - return new GeneralPlatformDomainRuleException(UNTRUSTED_ORIGIN, - "This deposit command has untrusted or unsupported origin metadata. Cancel it and resubmit the deposit."); + return SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.DEPOSIT); } public record Decoded(SavingsDepositOrigin origin, JsonObject payload) { diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionCommandEnvelope.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionCommandEnvelope.java new file mode 100644 index 00000000000..9d388d061f3 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionCommandEnvelope.java @@ -0,0 +1,126 @@ +/** + * 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.commands.domain; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; + +/** Shared codec. Deposit version 1 is unchanged; other kinds require version 2 and an explicit kind. */ +public final class SavingsTransactionCommandEnvelope { + + public static final String METADATA = "_serverCommand"; + + private SavingsTransactionCommandEnvelope() {} + + public static boolean appliesTo(String action, String entity) { + return SavingsTransactionKind.fromCommand(action, entity) != null; + } + + public static String encode(String json, SavingsTransactionKind kind, SavingsTransactionOrigin origin) { + JsonObject payload = clientPayload(json, kind); + if (!kind.accepts(origin)) { + throw untrustedOrigin(kind); + } + JsonObject metadata = new JsonObject(); + metadata.addProperty("version", kind == SavingsTransactionKind.DEPOSIT ? 1 : 2); + metadata.addProperty("origin", origin.name()); + if (kind != SavingsTransactionKind.DEPOSIT) { + metadata.addProperty("kind", kind.name()); + } + JsonObject envelope = new JsonObject(); + envelope.add(METADATA, metadata); + envelope.add("payload", payload); + return envelope.toString(); + } + + public static JsonObject clientPayload(String json, SavingsTransactionKind kind) { + final JsonObject payload; + try { + payload = JsonParser.parseString(json).getAsJsonObject(); + } catch (RuntimeException exception) { + throw new InvalidJsonException(); + } + if (payload.has(METADATA)) { + throw new GeneralPlatformDomainRuleException(errorPrefix(kind) + ".reserved.metadata", kind == SavingsTransactionKind.DEPOSIT + ? "The _serverCommand property is reserved for server use and must not be supplied in a deposit request." + : "The _serverCommand property is reserved for server use and must not be supplied in a savings transaction request."); + } + return payload; + } + + public static Decoded decode(String json, SavingsTransactionKind kind) { + try { + JsonObject envelope = JsonParser.parseString(json).getAsJsonObject(); + JsonObject metadata = envelope.getAsJsonObject(METADATA); + JsonElement version = metadata.get("version"); + JsonElement origin = metadata.get("origin"); + JsonObject payload = envelope.getAsJsonObject("payload"); + if (envelope.size() != 2 || metadata.size() != (kind == SavingsTransactionKind.DEPOSIT ? 2 : 3) || !version.isJsonPrimitive() + || !version.getAsJsonPrimitive().isNumber() + || !(kind == SavingsTransactionKind.DEPOSIT ? "1" : "2").equals(version.getAsString()) || !origin.isJsonPrimitive() + || !origin.getAsJsonPrimitive().isString() || payload == null || payload.has(METADATA)) { + throw untrustedOrigin(kind); + } + if (kind != SavingsTransactionKind.DEPOSIT && (!metadata.get("kind").isJsonPrimitive() + || !metadata.get("kind").getAsJsonPrimitive().isString() || !kind.name().equals(metadata.get("kind").getAsString()))) { + throw untrustedOrigin(kind); + } + SavingsTransactionOrigin trustedOrigin = SavingsTransactionOrigin.valueOf(origin.getAsString()); + if (!kind.accepts(trustedOrigin)) { + throw untrustedOrigin(kind); + } + return new Decoded(trustedOrigin, payload); + } catch (RuntimeException exception) { + throw untrustedOrigin(kind); + } + } + + /** Read-only compatibility: legacy flat history remains readable without establishing execution trust. */ + public static String forDisplay(String json) { + if (json == null || json.isBlank()) { + return json; + } + try { + JsonObject object = JsonParser.parseString(json).getAsJsonObject(); + if (!object.has(METADATA)) { + return json; + } + JsonElement payload = object.get("payload"); + return payload != null && payload.isJsonObject() ? payload.toString() : "{}"; + } catch (RuntimeException exception) { + return "{}"; + } + } + + public static GeneralPlatformDomainRuleException untrustedOrigin(SavingsTransactionKind kind) { + return new GeneralPlatformDomainRuleException(errorPrefix(kind) + ".untrusted.origin", kind == SavingsTransactionKind.DEPOSIT + ? "This deposit command has untrusted or unsupported origin metadata. Cancel it and resubmit the deposit." + : "This savings withdrawal command has untrusted or unsupported origin metadata. Cancel it and resubmit the operation."); + } + + private static String errorPrefix(SavingsTransactionKind kind) { + return kind == SavingsTransactionKind.DEPOSIT ? "error.msg.savings.deposit" : "error.msg.savings.withdrawal"; + } + + public record Decoded(SavingsTransactionOrigin origin, JsonObject payload) { + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionExecutionContext.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionExecutionContext.java new file mode 100644 index 00000000000..5debcb6d78a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionExecutionContext.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.commands.domain; + +import org.apache.fineract.useradministration.domain.AppUser; + +public record SavingsTransactionExecutionContext(SavingsTransactionKind kind, SavingsTransactionOrigin origin, AppUser maker) { + + public void requireKind(SavingsTransactionKind expected) { + if (kind != expected || maker == null || !expected.accepts(origin)) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(expected); + } + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionKind.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionKind.java new file mode 100644 index 00000000000..8315e0c0404 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionKind.java @@ -0,0 +1,44 @@ +/** + * 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.commands.domain; + +public enum SavingsTransactionKind { + + DEPOSIT, WITHDRAWAL, FORCE_WITHDRAWAL, ADJUSTTRANSACTION, CLOSE, GSIM_CLOSE; + + public static SavingsTransactionKind fromCommand(String action, String entity) { + if ("GSIMACCOUNT".equals(entity) && "CLOSE".equals(action)) { + return GSIM_CLOSE; + } + if (!"SAVINGSACCOUNT".equals(entity)) { + return null; + } + for (var kind : values()) { + if (kind != GSIM_CLOSE && kind.name().equals(action)) { + return kind; + } + } + return null; + } + + public boolean accepts(SavingsTransactionOrigin origin) { + return origin == SavingsTransactionOrigin.STAFF_API + || origin == SavingsTransactionOrigin.SPREADSHEET_IMPORT && (this == DEPOSIT || this == WITHDRAWAL); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionOrigin.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionOrigin.java new file mode 100644 index 00000000000..fac3196d669 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/SavingsTransactionOrigin.java @@ -0,0 +1,24 @@ +/** + * 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.commands.domain; + +/** Server-assigned provenance, never an exemption for withdrawal authority. */ +public enum SavingsTransactionOrigin { + STAFF_API, SPREADSHEET_IMPORT +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsTransactionCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsTransactionCommandHandler.java new file mode 100644 index 00000000000..f409ea0d90e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/handler/SavingsTransactionCommandHandler.java @@ -0,0 +1,28 @@ +/** + * 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.commands.handler; + +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface SavingsTransactionCommandHandler extends NewCommandSourceHandler { + + CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index c9189b53189..c4f914095cc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -30,12 +30,15 @@ import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; -import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; import org.apache.fineract.commands.domain.SavingsDepositExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; import org.apache.fineract.commands.handler.NewCommandSourceHandler; import org.apache.fineract.commands.handler.SavingsDepositCommandHandler; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -109,17 +112,18 @@ public CommandSource findCommandSource(CommandWrapper wrapper, String idempotenc public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) { CommandSource commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey, UNDER_PROCESSING.getValue(), false); - if (SavingsDepositCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { + if (SavingsTransactionCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { // Reject client metadata before any audit masking could remove the reserved key. - SavingsDepositCommandEnvelope.clientPayload(jsonCommand.json()); + SavingsTransactionCommandEnvelope.clientPayload(jsonCommand.json(), + SavingsTransactionKind.fromCommand(wrapper.actionName(), wrapper.entityName())); } sanitizeJson(commandSourceResult, wrapper.getSanitizeJsonKeys()); if (commandSourceResult.getCommandAsJson() == null) { commandSourceResult.setCommandAsJson("{}"); } - if (SavingsDepositCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { - commandSourceResult.setCommandAsJson( - SavingsDepositCommandEnvelope.encode(commandSourceResult.getCommandAsJson(), wrapper.getSavingsDepositOrigin())); + if (SavingsTransactionCommandEnvelope.appliesTo(wrapper.actionName(), wrapper.entityName())) { + commandSourceResult.setCommandAsJson(SavingsTransactionCommandEnvelope.encode(commandSourceResult.getCommandAsJson(), + SavingsTransactionKind.fromCommand(wrapper.actionName(), wrapper.entityName()), wrapper.getSavingsTransactionOrigin())); } return commandSourceResult; } @@ -129,19 +133,26 @@ public CommandExecutionResult processCommandAndSaveResult(NewCommandSourceHandle CommandSource commandSource, AppUser user, boolean isApprovedByChecker, BiConsumer resultUpdater) { final CommandProcessingResult result; - if (SavingsDepositCommandEnvelope.appliesTo(commandSource.getActionName(), commandSource.getEntityName())) { - var decoded = SavingsDepositCommandEnvelope.decode(commandSource.getCommandAsJson()); - if (!(handler instanceof SavingsDepositCommandHandler depositHandler)) { - throw SavingsDepositCommandEnvelope.untrustedOrigin(); - } + if (SavingsTransactionCommandEnvelope.appliesTo(commandSource.getActionName(), commandSource.getEntityName())) { + var kind = SavingsTransactionKind.fromCommand(commandSource.getActionName(), commandSource.getEntityName()); + var decoded = SavingsTransactionCommandEnvelope.decode(commandSource.getCommandAsJson(), kind); JsonCommand flatCommand = JsonCommand.fromExistingCommand(command.commandId(), decoded.payload().toString(), decoded.payload(), fromApiJsonHelper, commandSource.getEntityName(), commandSource.getResourceId(), commandSource.getSubResourceId(), commandSource.getGroupId(), commandSource.getClientId(), commandSource.getLoanId(), commandSource.getSavingsId(), commandSource.getTransactionId(), commandSource.getResourceGetUrl(), commandSource.getProductId(), commandSource.getCreditBureauId(), commandSource.getOrganisationCreditBureauId(), commandSource.getJobName(), commandSource.getLoanExternalId()); - result = depositHandler.processDeposit(flatCommand, - new SavingsDepositExecutionContext(decoded.origin(), commandSource.getMaker())); + if (kind == SavingsTransactionKind.DEPOSIT && handler instanceof SavingsDepositCommandHandler depositHandler) { + result = depositHandler.processDeposit(flatCommand, + new SavingsDepositExecutionContext( + org.apache.fineract.commands.domain.SavingsDepositOrigin.valueOf(decoded.origin().name()), + commandSource.getMaker())); + } else if (handler instanceof SavingsTransactionCommandHandler transactionHandler) { + result = transactionHandler.processTransaction(flatCommand, + new SavingsTransactionExecutionContext(kind, decoded.origin(), commandSource.getMaker())); + } else { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(kind); + } } else { result = handler.processCommand(command); } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 74d4a3dcae4..913c067e483 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -253,6 +253,7 @@ import java.util.Set; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; import org.apache.fineract.infrastructure.accountnumberformat.service.AccountNumberFormatConstants; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.client.api.ClientApiConstants; @@ -262,9 +263,16 @@ public class CommandWrapperBuilder { private SavingsDepositOrigin savingsDepositOrigin; + private SavingsTransactionOrigin savingsTransactionOrigin; + + public CommandWrapperBuilder withSavingsTransactionOrigin(SavingsTransactionOrigin origin) { + this.savingsTransactionOrigin = origin; + return this; + } public CommandWrapperBuilder withSavingsDepositOrigin(SavingsDepositOrigin origin) { this.savingsDepositOrigin = origin; + this.savingsTransactionOrigin = origin == null ? null : SavingsTransactionOrigin.valueOf(origin.name()); return this; } @@ -288,14 +296,15 @@ public CommandWrapperBuilder withSavingsDepositOrigin(SavingsDepositOrigin origi public CommandWrapper build() { return new CommandWrapper(null, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, null, null, - this.organisationCreditBureauId, this.jobName, null, this.loanExternalId, this.sanitizeJsonKeys, this.savingsDepositOrigin); + this.organisationCreditBureauId, this.jobName, null, this.loanExternalId, this.sanitizeJsonKeys, this.savingsDepositOrigin, + this.savingsTransactionOrigin); } public CommandWrapper build(String idempotencyKey) { return new CommandWrapper(null, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, null, null, this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId, this.sanitizeJsonKeys, - this.savingsDepositOrigin); + this.savingsDepositOrigin, this.savingsTransactionOrigin); } public CommandWrapperBuilder updateCreditBureau() { diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index a5130199ff2..fb1ef355ca7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -26,7 +26,7 @@ import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; -import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.UnsupportedCommandException; @@ -96,8 +96,8 @@ public CommandProcessingResult approveEntry(final Long makerCheckerId) { commandSourceInput.getLoanExternalId()); // Unwrap for presentation (including hooks); execution separately establishes trust in the persisted metadata. String executionJson = commandSourceInput.getCommandAsJson(); - if (SavingsDepositCommandEnvelope.appliesTo(commandSourceInput.getActionName(), commandSourceInput.getEntityName())) { - executionJson = SavingsDepositCommandEnvelope.forDisplay(executionJson); + if (SavingsTransactionCommandEnvelope.appliesTo(commandSourceInput.getActionName(), commandSourceInput.getEntityName())) { + executionJson = SavingsTransactionCommandEnvelope.forDisplay(executionJson); if (executionJson == null || executionJson.isBlank()) { executionJson = "{}"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java index 1eda538c66f..217570c6ccd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/commands/service/AuditReadPlatformServiceImpl.java @@ -39,7 +39,7 @@ import org.apache.fineract.commands.data.AuditSearchData; import org.apache.fineract.commands.data.ProcessingResultLookup; import org.apache.fineract.commands.data.request.AuditRequest; -import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.infrastructure.core.data.PaginationParameters; import org.apache.fineract.infrastructure.core.data.PaginationParametersDataValidator; @@ -168,8 +168,8 @@ public AuditData mapRow(final ResultSet rs, @SuppressWarnings("unused") final in ZonedDateTime madeOnDate = madeOnDateUTC != null ? madeOnDateUTC.toZonedDateTime() : madeOnDateTenant; ZonedDateTime checkedOnDate = checkedOnDateUTC != null ? checkedOnDateUTC.toZonedDateTime() : checkedOnDateTenant; - if (SavingsDepositCommandEnvelope.appliesTo(actionName, entityName)) { - commandAsJson = SavingsDepositCommandEnvelope.forDisplay(commandAsJson); + if (SavingsTransactionCommandEnvelope.appliesTo(actionName, entityName)) { + commandAsJson = SavingsTransactionCommandEnvelope.forDisplay(commandAsJson); } return new AuditData(id, actionName, entityName, resourceId, subresourceId, maker, madeOnDate, checker, checkedOnDate, processingResult, commandAsJson, officeName, groupLevelName, groupName, clientName, loanAccountNo, savingsAccountNo, diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java index e138f32d545..f1b7a5a7b46 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/savings/SavingsTransactionImportHandler.java @@ -26,6 +26,7 @@ import java.util.List; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants; @@ -132,11 +133,17 @@ private Count importEntity(final Workbook workbook, final List builder.savingsAccountDeposit(resolvedSavingsId).withSavingsDepositOrigin(SavingsDepositOrigin.STAFF_API).build(); case "gsimDeposit" -> builder.gsimSavingsAccountDeposit(resolvedSavingsId).build(); - case "withdrawal" -> builder.savingsAccountWithdrawal(resolvedSavingsId).build(); - case "force-withdrawal" -> builder.savingsAccountForceWithdrawal(resolvedSavingsId).build(); + case "withdrawal" -> builder.savingsAccountWithdrawal(resolvedSavingsId) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).build(); + case "force-withdrawal" -> builder.savingsAccountForceWithdrawal(resolvedSavingsId) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).build(); case "postInterestAsOn" -> builder.savingsAccountInterestPosting(resolvedSavingsId).build(); case SavingsApiConstants.COMMAND_HOLD_AMOUNT -> builder.holdAmount(resolvedSavingsId).build(); default -> throw new UnrecognizedQueryParamException("command", commandParam, "deposit", "withdrawal", "force-withdrawal", @@ -376,7 +379,8 @@ private String adjustTransaction(final Long savingsId, final String savingsExter case SavingsApiConstants.COMMAND_REVERSE_TRANSACTION -> builder.reverseSavingsAccountTransaction(resolvedSavingsId, resolvedTransactionId).build(); case SavingsApiConstants.COMMAND_ADJUST_TRANSACTION -> - builder.adjustSavingsAccountTransaction(resolvedSavingsId, resolvedTransactionId).build(); + builder.adjustSavingsAccountTransaction(resolvedSavingsId, resolvedTransactionId) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).build(); case SavingsApiConstants.COMMAND_RELEASE_AMOUNT -> builder.releaseAmount(resolvedSavingsId, resolvedTransactionId).build(); default -> throw new UnrecognizedQueryParamException("command", commandParam, SavingsApiConstants.COMMAND_UNDO_TRANSACTION, SavingsApiConstants.COMMAND_ADJUST_TRANSACTION, SavingsApiConstants.COMMAND_RELEASE_AMOUNT, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index 5312e4651db..b1b8fdd4c2b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -46,6 +46,7 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.bulkimport.data.GlobalEntityType; @@ -325,7 +326,8 @@ public String handleGSIMCommands(@PathParam("parentAccountId") final Long parent final CommandWrapper commandRequest = builder.savingsAccountApplyAnnualFees(parentAccountId).build(); result = commandsSourceWritePlatformService.logCommandSource(commandRequest); } else if (is(commandParam, "close")) { - final CommandWrapper commandRequest = builder.closeGSIMApplication(parentAccountId).build(); + final CommandWrapper commandRequest = builder.closeGSIMApplication(parentAccountId) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).build(); result = commandsSourceWritePlatformService.logCommandSource(commandRequest); } if (result == null) { @@ -577,7 +579,8 @@ private String handleCommands(Long accountId, String externalId, String commandP final CommandWrapper commandRequest = builder.savingsAccountApplyAnnualFees(accountId).build(); result = commandsSourceWritePlatformService.logCommandSource(commandRequest); } else if (is(commandParam, "close")) { - final CommandWrapper commandRequest = builder.closeSavingsAccountApplication(accountId).build(); + final CommandWrapper commandRequest = builder.closeSavingsAccountApplication(accountId) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).build(); result = commandsSourceWritePlatformService.logCommandSource(commandRequest); } else if (is(commandParam, "assignSavingsOfficer")) { final CommandWrapper commandRequest = builder.assignSavingsOfficer(accountId).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseGSIMCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseGSIMCommandHandler.java index d03b23abe7a..b52880d87d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseGSIMCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseGSIMCommandHandler.java @@ -16,12 +16,14 @@ * specific language governing permissions and limitations * under the License. */ - package org.apache.fineract.portfolio.savings.handler; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; @@ -31,13 +33,23 @@ @Service @CommandType(entity = "GSIMACCOUNT", action = "CLOSE") @RequiredArgsConstructor -public class CloseGSIMCommandHandler implements NewCommandSourceHandler { +public class CloseGSIMCommandHandler implements SavingsTransactionCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.bulkGSIMClose(command.getSavingsId(), command); + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.GSIM_CLOSE); + } + + @Transactional + @Override + public CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.GSIM_CLOSE); + } + context.requireKind(SavingsTransactionKind.GSIM_CLOSE); + return this.writePlatformService.bulkGSIMClose(command.getSavingsId(), command, context); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseSavingsAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseSavingsAccountCommandHandler.java index dd1844b9537..680b1717dad 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseSavingsAccountCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CloseSavingsAccountCommandHandler.java @@ -20,7 +20,10 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; @@ -30,14 +33,23 @@ @Service @CommandType(entity = "SAVINGSACCOUNT", action = "CLOSE") @RequiredArgsConstructor -public class CloseSavingsAccountCommandHandler implements NewCommandSourceHandler { +public class CloseSavingsAccountCommandHandler implements SavingsTransactionCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.close(command.getSavingsId(), command); + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.CLOSE); } + @Transactional + @Override + public CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.CLOSE); + } + context.requireKind(SavingsTransactionKind.CLOSE); + return this.writePlatformService.close(command.getSavingsId(), command, context); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ForceWithdrawalSavingsAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ForceWithdrawalSavingsAccountCommandHandler.java index de7c213755e..6f94993aae0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ForceWithdrawalSavingsAccountCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ForceWithdrawalSavingsAccountCommandHandler.java @@ -20,23 +20,45 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsWithdrawalAuthorityService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @CommandType(entity = "SAVINGSACCOUNT", action = "FORCE_WITHDRAWAL") @RequiredArgsConstructor -public class ForceWithdrawalSavingsAccountCommandHandler implements NewCommandSourceHandler { +public class ForceWithdrawalSavingsAccountCommandHandler implements SavingsTransactionCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; + private final SavingsWithdrawalAuthorityService authority; + private final SavingsAccountRepositoryWrapper accounts; + private final SavingsAccountTransactionDataValidator validator; @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.FORCE_WITHDRAWAL); + } + + @Transactional + @Override + public CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.FORCE_WITHDRAWAL); + } + context.requireKind(SavingsTransactionKind.FORCE_WITHDRAWAL); + validator.validate(command); + authority.require(context, SavingsTransactionKind.FORCE_WITHDRAWAL, accounts.findOneWithNotFoundDetection(command.getSavingsId()), + command.bigDecimalValueOfParameterNamed("transactionAmount")); return this.writePlatformService.forceWithdrawal(command.getSavingsId(), command); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/SavingsTransactionAdjustmentCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/SavingsTransactionAdjustmentCommandHandler.java index 14f64135797..8b8a3b7bc97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/SavingsTransactionAdjustmentCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/SavingsTransactionAdjustmentCommandHandler.java @@ -20,7 +20,10 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; @@ -30,15 +33,24 @@ @Service @CommandType(entity = "SAVINGSACCOUNT", action = "ADJUSTTRANSACTION") @RequiredArgsConstructor -public class SavingsTransactionAdjustmentCommandHandler implements NewCommandSourceHandler { +public class SavingsTransactionAdjustmentCommandHandler implements SavingsTransactionCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { - final Long transactionId = Long.valueOf(command.getTransactionId()); - return this.writePlatformService.adjustSavingsTransaction(command.getSavingsId(), transactionId, command); + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.ADJUSTTRANSACTION); } + @Transactional + @Override + public CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.ADJUSTTRANSACTION); + } + context.requireKind(SavingsTransactionKind.ADJUSTTRANSACTION); + return this.writePlatformService.adjustSavingsTransaction(command.getSavingsId(), Long.valueOf(command.getTransactionId()), command, + context); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/WithdrawSavingsAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/WithdrawSavingsAccountCommandHandler.java index 9819a788f7b..6fc6314fe2a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/WithdrawSavingsAccountCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/WithdrawSavingsAccountCommandHandler.java @@ -20,23 +20,45 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; -import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.handler.SavingsTransactionCommandHandler; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsWithdrawalAuthorityService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @CommandType(entity = "SAVINGSACCOUNT", action = "WITHDRAWAL") @RequiredArgsConstructor -public class WithdrawSavingsAccountCommandHandler implements NewCommandSourceHandler { +public class WithdrawSavingsAccountCommandHandler implements SavingsTransactionCommandHandler { private final SavingsAccountWritePlatformService writePlatformService; + private final SavingsWithdrawalAuthorityService authority; + private final SavingsAccountRepositoryWrapper accounts; + private final SavingsAccountTransactionDataValidator validator; @Transactional @Override public CommandProcessingResult processCommand(final JsonCommand command) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.WITHDRAWAL); + } + + @Transactional + @Override + public CommandProcessingResult processTransaction(JsonCommand command, SavingsTransactionExecutionContext context) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.WITHDRAWAL); + } + context.requireKind(SavingsTransactionKind.WITHDRAWAL); + validator.validate(command); + authority.require(context, SavingsTransactionKind.WITHDRAWAL, accounts.findOneWithNotFoundDetection(command.getSavingsId()), + command.bigDecimalValueOfParameterNamed("transactionAmount")); return this.writePlatformService.withdrawal(command.getSavingsId(), command); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 9fa5c730de8..5e1e424324d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -49,6 +49,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; @@ -168,6 +171,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi private final SavingsAccountActivationService savingsAccountActivationService; private final ExternalIdFactory externalIdFactory; private final ErrorHandler errorHandler; + private final SavingsWithdrawalAuthorityService withdrawalAuthority; @Transactional @Override @@ -867,6 +871,13 @@ public CommandProcessingResult undoTransaction(final Long savingsId, final Long @Override public CommandProcessingResult adjustSavingsTransaction(final Long savingsId, final Long transactionId, final JsonCommand command) { + return adjustSavingsTransaction(savingsId, transactionId, command, null); + } + + @Override + @Transactional + public CommandProcessingResult adjustSavingsTransaction(final Long savingsId, final Long transactionId, final JsonCommand command, + SavingsTransactionExecutionContext executionContext) { context.authenticatedUser(); final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService @@ -895,6 +906,10 @@ public CommandProcessingResult adjustSavingsTransaction(final Long savingsId, fi final LocalDate today = DateUtils.getBusinessLocalDate(); final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); + if (savingsAccountTransaction.isWithdrawal()) { + withdrawalAuthority.require(executionContext, SavingsTransactionKind.ADJUSTTRANSACTION, account, + command.bigDecimalValueOfParameterNamed(SavingsApiConstants.transactionAmountParamName)); + } if (account.isNotActive()) { throwValidationForActiveStatus(SavingsApiConstants.adjustTransactionAction); @@ -998,6 +1013,19 @@ private void checkClientOrGroupActive(final SavingsAccount account) { @Override public CommandProcessingResult bulkGSIMClose(final Long gsimId, final JsonCommand command) { + return bulkGSIMClose(gsimId, command, null); + } + + @Override + @Transactional + public CommandProcessingResult bulkGSIMClose(final Long gsimId, final JsonCommand command, + SavingsTransactionExecutionContext executionContext) { + if (executionContext == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(SavingsTransactionKind.GSIM_CLOSE); + } + executionContext.requireKind(SavingsTransactionKind.GSIM_CLOSE); + final var childContext = new SavingsTransactionExecutionContext(SavingsTransactionKind.CLOSE, executionContext.origin(), + executionContext.maker()); final Long parentSavingId = gsimId; GroupSavingsIndividualMonitoring parentSavings = gsimRepository.findById(parentSavingId).orElseThrow(); @@ -1006,7 +1034,7 @@ public CommandProcessingResult bulkGSIMClose(final Long gsimId, final JsonComman CommandProcessingResult result = null; int count = 0; for (SavingsAccount account : childSavings) { - result = close(account.getId(), command); + result = close(account.getId(), command, childContext); if (result != null) { count++; @@ -1021,6 +1049,13 @@ public CommandProcessingResult bulkGSIMClose(final Long gsimId, final JsonComman @Override public CommandProcessingResult close(final Long savingsId, final JsonCommand command) { + return close(savingsId, command, null); + } + + @Override + @Transactional + public CommandProcessingResult close(final Long savingsId, final JsonCommand command, + SavingsTransactionExecutionContext executionContext) { final AppUser user = this.context.authenticatedUser(); final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); @@ -1064,6 +1099,7 @@ public CommandProcessingResult close(final Long savingsId, final JsonCommand com if (isWithdrawBalance && account.getSummary().getAccountBalance(account.getCurrency()).isGreaterThanZero()) { final BigDecimal transactionAmount = account.getSummary().getAccountBalance(); + withdrawalAuthority.require(executionContext, SavingsTransactionKind.CLOSE, account, transactionAmount); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsWithdrawalAuthorityService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsWithdrawalAuthorityService.java new file mode 100644 index 00000000000..f3340680d23 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsWithdrawalAuthorityService.java @@ -0,0 +1,50 @@ +/** + * 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.service; + +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyService; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SavingsWithdrawalAuthorityService { + + private final NsimbiMonetaryAuthorityPolicyService monetaryAuthority; + + public void require(SavingsTransactionExecutionContext context, SavingsTransactionKind kind, SavingsAccount account, + BigDecimal amount) { + if (context == null) { + throw SavingsTransactionCommandEnvelope.untrustedOrigin(kind); + } + context.requireKind(kind); + if (!monetaryAuthority.allows(context.maker().getId(), MonetaryAuthorityType.WITHDRAWALS, account.getCurrency().getCode(), + amount)) { + throw new GeneralPlatformDomainRuleException("error.msg.savings.withdrawal.monetary.authority.denied", + "The original submitter does not have WITHDRAWALS monetary authority for this amount and account currency."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index f4960a5a9bb..5bb75f4d444 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -144,6 +144,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask; +import org.apache.fineract.portfolio.savings.service.SavingsWithdrawalAuthorityService; import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService; import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionsSearchServiceImpl; import org.apache.fineract.portfolio.search.service.SearchUtil; @@ -396,8 +397,8 @@ public SavingsAccountWritePlatformService savingsAccountWritePlatformService(Pla StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, SavingsAccountPostInterestService savingsAccountPostInterestService, - SavingsAccountActivationService savingsAccountActivationService, ExternalIdFactory externalIdFactory, - ErrorHandler errorHandler) { + SavingsAccountActivationService savingsAccountActivationService, ExternalIdFactory externalIdFactory, ErrorHandler errorHandler, + SavingsWithdrawalAuthorityService withdrawalAuthority) { return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, savingsAccountDomainService, noteRepository, @@ -405,7 +406,7 @@ public SavingsAccountWritePlatformService savingsAccountWritePlatformService(Pla savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, - savingsAccountPostInterestService, savingsAccountActivationService, externalIdFactory, errorHandler); + savingsAccountPostInterestService, savingsAccountActivationService, externalIdFactory, errorHandler, withdrawalAuthority); } @Bean diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java index 609014a768c..7707d84ee88 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsDepositAuditTest.java @@ -41,12 +41,22 @@ void auditMapperReturnsFlatPayloadForLegacyAndEnvelopedDeposits(boolean envelope } @ParameterizedTest - @ValueSource(strings = { "WITHDRAWAL", "POSTINTEREST", "ACTIVATE" }) + @ValueSource(strings = { "POSTINTEREST", "ACTIVATE" }) void unrelatedAuditCommandsAreUntouched(String action) throws Exception { String stored = "{\"_serverCommand\":\"ordinary data for another command\"}"; assertThat(map(action, stored).getCommandAsJson()).isEqualTo(stored); } + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void withdrawalAuditShowsBusinessPayloadForCompletedLegacyAndNewCommands(boolean enveloped) throws Exception { + String payload = "{\"transactionAmount\":50}"; + String stored = enveloped ? org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope.encode(payload, + org.apache.fineract.commands.domain.SavingsTransactionKind.WITHDRAWAL, + org.apache.fineract.commands.domain.SavingsTransactionOrigin.STAFF_API) : payload; + assertThat(map("WITHDRAWAL", stored).getCommandAsJson()).isEqualTo(payload); + } + private AuditData map(String action, String stored) throws Exception { // Exercise the actual shared mapper used by audit detail, list and maker-checker queries. var constructor = Class.forName(AuditReadPlatformServiceImpl.class.getName() + "$AuditMapper").getDeclaredConstructor(); diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsWithdrawalEnvelopeTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsWithdrawalEnvelopeTest.java new file mode 100644 index 00000000000..af67e9fe1a5 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SavingsWithdrawalEnvelopeTest.java @@ -0,0 +1,75 @@ +/** + * 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.commands.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.fineract.commands.domain.SavingsDepositCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsDepositOrigin; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class SavingsWithdrawalEnvelopeTest { + + @ParameterizedTest + @EnumSource(SavingsDepositOrigin.class) + void depositVersionOneIsByteCompatible(SavingsDepositOrigin origin) { + String expected = "{\"_serverCommand\":{\"version\":1,\"origin\":\"" + origin.name() + + "\"},\"payload\":{\"transactionAmount\":50}}"; + assertThat(SavingsDepositCommandEnvelope.encode("{\"transactionAmount\":50}", origin)).isEqualTo(expected); + assertThat(SavingsDepositCommandEnvelope.decode(expected).origin()).isEqualTo(origin); + assertThrows(GeneralPlatformDomainRuleException.class, + () -> SavingsTransactionCommandEnvelope.decode(expected, SavingsTransactionKind.WITHDRAWAL)); + } + + @ParameterizedTest + @EnumSource(SavingsTransactionKind.class) + void typedMetadataCannotCrossKinds(SavingsTransactionKind kind) { + String stored = SavingsTransactionCommandEnvelope.encode("{}", kind, SavingsTransactionOrigin.STAFF_API); + assertThat(SavingsTransactionCommandEnvelope.decode(stored, kind).payload().toString()).isEqualTo("{}"); + for (var other : SavingsTransactionKind.values()) { + if (other != kind) { + assertThrows(GeneralPlatformDomainRuleException.class, () -> SavingsTransactionCommandEnvelope.decode(stored, other)); + } + } + } + + @ParameterizedTest + @ValueSource(strings = { "null", "[]", "{}", "bad json", "{\"_serverCommand\":null}", + "{\"_serverCommand\":{\"version\":2,\"kind\":\"WITHDRAWAL\",\"origin\":\"UNKNOWN\"},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":3,\"kind\":\"WITHDRAWAL\",\"origin\":\"STAFF_API\"},\"payload\":{}}" }) + void malformedHistoryFailsClosedButDisplayRemainsReadable(String stored) { + var failure = assertThrows(GeneralPlatformDomainRuleException.class, + () -> SavingsTransactionCommandEnvelope.decode(stored, SavingsTransactionKind.WITHDRAWAL)); + assertThat(failure.getDefaultUserMessage()).contains("Cancel", "resubmit"); + SavingsTransactionCommandEnvelope.forDisplay(stored); + } + + @Test + void legacyCompletedHistoryStaysFlat() { + assertThat(SavingsTransactionCommandEnvelope.forDisplay("{\"transactionAmount\":50}")).isEqualTo("{\"transactionAmount\":50}"); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsWithdrawalOriginTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsWithdrawalOriginTest.java new file mode 100644 index 00000000000..98523d00ac2 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/api/SavingsWithdrawalOriginTest.java @@ -0,0 +1,157 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Set; +import org.apache.fineract.batch.command.internal.SavingsAccountTransactionCommandStrategy; +import org.apache.fineract.batch.domain.BatchRequest; +import org.apache.fineract.batch.domain.Header; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; +import org.apache.fineract.commands.service.CommandSourceService; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants; +import org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants; +import org.apache.fineract.infrastructure.bulkimport.importhandler.savings.SavingsTransactionImportHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@ExtendWith(MockitoExtension.class) +class SavingsWithdrawalOriginTest { + + @Mock + private PortfolioCommandSourceWritePlatformService commands; + @Mock + private DefaultToApiJsonSerializer serializer; + @Mock + private SavingsAccountReadPlatformService accounts; + @InjectMocks + private SavingsAccountTransactionsApiResource resource; + + @BeforeEach + void setTenant() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test", "UTC", null)); + } + + @AfterEach + void clearRequest() { + RequestContextHolder.resetRequestAttributes(); + ThreadLocalContextUtil.reset(); + } + + @ParameterizedTest + @ValueSource(strings = { "direct", "externalId", "batch" }) + void apiAssignsStaffOriginDespiteSpoofedQueryAndHeaders(String route) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("origin", "SPREADSHEET_IMPORT"); + request.addHeader("_serverCommand", "SPREADSHEET_IMPORT"); + request.addParameter("origin", "SPREADSHEET_IMPORT"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + when(commands.logCommandSource(any())).thenReturn(new CommandProcessingResultBuilder().withSavingsId(42L).build()); + invoke(route, "{}"); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(CommandWrapper.class); + verify(commands).logCommandSource(wrapper.capture()); + assertThat(wrapper.getValue().getSavingsTransactionOrigin()).isEqualTo(SavingsTransactionOrigin.STAFF_API); + assertThat(wrapper.getValue().getJson()).isEqualTo("{}"); + } + + @ParameterizedTest + @ValueSource(strings = { "direct", "batch" }) + void clientEnvelopeInjectionIsRejectedByPersistenceBoundary(String route) { + FromJsonHelper json = new FromJsonHelper(); + CommandSourceService sources = new CommandSourceService(null, null, null, json); + when(commands.logCommandSource(any())).thenAnswer(invocation -> { + CommandWrapper wrapper = invocation.getArgument(0); + sources.getInitialCommandSource(wrapper, JsonCommand.from(wrapper.getJson()), mock(AppUser.class), "test"); + throw new AssertionError("Injected metadata was accepted"); + }); + var failure = assertThrows(GeneralPlatformDomainRuleException.class, + () -> invoke(route, "{\"_serverCommand\":{\"version\":1,\"origin\":\"SPREADSHEET_IMPORT\"},\"payload\":{}}")); + assertThat(failure.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.withdrawal.reserved.metadata"); + } + + @Test + void spreadsheetBothKindsCarryTrustedProvenance() throws Exception { + try (var workbook = new HSSFWorkbook()) { + var sheet = workbook.createSheet(TemplatePopulateImportConstants.SAVINGS_TRANSACTION_SHEET_NAME); + workbook.createSheet(TemplatePopulateImportConstants.EXTRAS_SHEET_NAME); + sheet.createRow(0); + for (int i = 1; i <= TransactionConstants.STATUS_COL; i++) { + sheet.createRow(i); + } + for (int i = 1; i <= 2; i++) { + var row = sheet.getRow(i); + row.createCell(TransactionConstants.SAVINGS_ACCOUNT_NO_COL).setCellValue(42); + row.createCell(TransactionConstants.TRANSACTION_TYPE_COL).setCellValue(i == 1 ? "Deposit" : "Withdrawal"); + row.createCell(TransactionConstants.AMOUNT_COL).setCellValue(10000); + row.createCell(TransactionConstants.TRANSACTION_DATE_COL).setCellValue(LocalDate.of(2026, 9, 24)); + } + new SavingsTransactionImportHandler(commands).process(workbook, "en", "yyyy-MM-dd"); + ArgumentCaptor captured = ArgumentCaptor.forClass(CommandWrapper.class); + verify(commands, org.mockito.Mockito.times(2)).logCommandSource(captured.capture()); + assertThat(captured.getAllValues().get(0).getSavingsTransactionOrigin()).isEqualTo(SavingsTransactionOrigin.SPREADSHEET_IMPORT); + assertThat(captured.getAllValues().get(1).getSavingsTransactionOrigin()).isEqualTo(SavingsTransactionOrigin.SPREADSHEET_IMPORT); + assertThat(captured.getAllValues().get(1).actionName()).isEqualTo("WITHDRAWAL"); + } + } + + private void invoke(String route, String body) { + switch (route) { + case "externalId" -> { + when(accounts.retrieveAccountIdByExternalId(any())).thenReturn(42L); + resource.transaction("external-reference", "withdrawal", body); + } + case "batch" -> + new SavingsAccountTransactionCommandStrategy(resource).execute(new BatchRequest().setRequestId(1L).setRelativeUrl( + "savingsaccounts/42/transactions?command=withdrawal&origin=SPREADSHEET_IMPORT&_serverCommand=SPREADSHEET_IMPORT") + .setBody(body).setHeaders(Set.of(new Header().setName("origin").setValue("SPREADSHEET_IMPORT"))), null); + default -> resource.transaction(42L, "withdrawal", body); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java index 7d5f892dc82..14e8ef85c36 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsDepositAuthorityTest.java @@ -379,16 +379,11 @@ void contextFreeHandlerInvocationAndMissingMakerFailClosed() { } @ParameterizedTest - @ValueSource(strings = { "WITHDRAWAL", "POSTINTEREST", "CALCULATEINTEREST", "GSIM", "TRANSFER" }) + @ValueSource(strings = { "POSTINTEREST", "CALCULATEINTEREST", "GSIM", "TRANSFER" }) void excludedCommandsKeepTheirHandlersAndFlatStorage(String kind) { var result = new CommandProcessingResultBuilder().withSavingsId(42L).build(); CommandWrapper wrapper; switch (kind) { - case "WITHDRAWAL" -> { - when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new WithdrawSavingsAccountCommandHandler(savings)); - when(savings.withdrawal(eq(42L), any())).thenReturn(result); - wrapper = new CommandWrapperBuilder().savingsAccountWithdrawal(42L).build(); - } case "POSTINTEREST" -> { when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new PostInterestSavingsAccountCommandHandler(savings)); when(savings.postInterest(any(JsonCommand.class))).thenReturn(result); diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalAuthorityTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalAuthorityTest.java new file mode 100644 index 00000000000..65164ba0201 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalAuthorityTest.java @@ -0,0 +1,603 @@ +/** + * 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.handler; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants.EXTRAS_SHEET_NAME; +import static org.apache.fineract.infrastructure.bulkimport.constants.TemplatePopulateImportConstants.SAVINGS_TRANSACTION_SHEET_NAME; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.AMOUNT_COL; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.PAYMENT_TYPE_COL; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.SAVINGS_ACCOUNT_NO_COL; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.STATUS_COL; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.TRANSACTION_DATE_COL; +import static org.apache.fineract.infrastructure.bulkimport.constants.TransactionConstants.TRANSACTION_TYPE_COL; +import static org.apache.fineract.infrastructure.bulkimport.data.GlobalEntityType.SAVINGS_TRANSACTIONS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import org.apache.fineract.command.core.CommandDispatcher; +import org.apache.fineract.commands.api.MakercheckersApiResource; +import org.apache.fineract.commands.configuration.RetryConfigurationAssembler; +import org.apache.fineract.commands.domain.CommandProcessingResultType; +import org.apache.fineract.commands.domain.CommandSource; +import org.apache.fineract.commands.domain.CommandSourceRepository; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.domain.SavingsTransactionCommandEnvelope; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; +import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; +import org.apache.fineract.commands.provider.CommandHandlerProvider; +import org.apache.fineract.commands.service.CommandSourceService; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.IdempotencyKeyResolver; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformServiceImpl; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; +import org.apache.fineract.infrastructure.bulkimport.data.BulkImportEvent; +import org.apache.fineract.infrastructure.bulkimport.domain.ImportDocument; +import org.apache.fineract.infrastructure.bulkimport.domain.ImportDocumentRepository; +import org.apache.fineract.infrastructure.bulkimport.importhandler.savings.SavingsTransactionImportHandler; +import org.apache.fineract.infrastructure.bulkimport.service.BulkImportEventListener; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.configuration.service.BackdatedTransactionValidationService; +import org.apache.fineract.infrastructure.contentstore.util.ContentPipe; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.config.SpringConfig; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; +import org.apache.fineract.infrastructure.core.exceptionmapper.DefaultExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.PlatformDomainRuleExceptionMapper; +import org.apache.fineract.infrastructure.core.exceptionmapper.RollbackTransactionNotApprovedExceptionMapper; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.TransactionBoundApplicationEventPublisher; +import org.apache.fineract.infrastructure.documentmanagement.command.DocumentUpdateCommand; +import org.apache.fineract.infrastructure.jobs.service.SchedulerJobRunnerReadService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.service.SpringSecurityPlatformSecurityContext; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthority; +import org.apache.fineract.nsimbi.userroles.domain.NsimbiUserMonetaryAuthorityRepository; +import org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyService; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.account.handler.CreateAccountTransferCommandHandler; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.savings.service.SavingsWithdrawalAuthorityService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationListener; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +class SavingsWithdrawalAuthorityTest { + + private final ConfigurationDomainService configuration = mock(ConfigurationDomainService.class); + private final CommandSourceRepository repository = mock(CommandSourceRepository.class); + private final PlatformSecurityContext security = mock(PlatformSecurityContext.class); + private final CommandHandlerProvider handlers = mock(CommandHandlerProvider.class); + private final SavingsAccountWritePlatformService savings = mock(SavingsAccountWritePlatformService.class); + private final AppUser user = mock(AppUser.class); + private final IdempotencyKeyResolver idempotency = mock(IdempotencyKeyResolver.class); + @SuppressWarnings("unchecked") + private final ToApiJsonSerializer resultSerializer = mock(ToApiJsonSerializer.class); + private final List savedStates = new ArrayList<>(); + private final StaticApplicationContext applicationContext = new StaticApplicationContext(); + private final NsimbiUserMonetaryAuthorityRepository authorities = mock(NsimbiUserMonetaryAuthorityRepository.class); + private final SavingsAccountRepositoryWrapper accounts = mock(SavingsAccountRepositoryWrapper.class); + private CommandSource savedSource; + private ErrorHandler errors; + private PortfolioCommandSourceWritePlatformServiceImpl commands; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test Tenant", "UTC", null)); + newRequest(); + applicationContext.getBeanFactory().registerSingleton("unsupported", new PlatformDomainRuleExceptionMapper()); + applicationContext.getBeanFactory().registerSingleton("awaitingApproval", new RollbackTransactionNotApprovedExceptionMapper()); + errors = new ErrorHandler(applicationContext, new DefaultExceptionMapper()); + FromJsonHelper json = new FromJsonHelper(); + CommandSourceService sources = new CommandSourceService(configuration, repository, errors, json); + RetryConfigurationAssembler retries = mock(RetryConfigurationAssembler.class); + when(retries.getRetryConfigurationForExecuteCommand()).thenReturn(Retry.of("test", RetryConfig.custom().maxAttempts(1).build())); + @SuppressWarnings("unchecked") + ToApiJsonSerializer> hookSerializer = mock(ToApiJsonSerializer.class); + SynchronousCommandProcessingService processing = new SynchronousCommandProcessingService(security, applicationContext, + mock(TransactionBoundApplicationEventPublisher.class), hookSerializer, resultSerializer, configuration, handlers, + idempotency, sources, retries, new FineractRequestContextHolder()); + commands = new PortfolioCommandSourceWritePlatformServiceImpl(security, repository, json, processing, + mock(SchedulerJobRunnerReadService.class), configuration, List.of()); + when(security.authenticatedUser(any(CommandWrapper.class))).thenReturn(user); + when(security.authenticatedUser()).thenReturn(user); + when(user.getId()).thenReturn(2L); + when(idempotency.resolve(any())).thenReturn("withdrawal-test"); + when(handlers.getHandler("SAVINGSACCOUNT", "WITHDRAWAL")).thenReturn(new WithdrawSavingsAccountCommandHandler(savings, + new SavingsWithdrawalAuthorityService(new NsimbiMonetaryAuthorityPolicyService(authorities)), accounts, + new SavingsAccountTransactionDataValidator(json, configuration, mock(BackdatedTransactionValidationService.class)))); + SavingsAccount account = mock(SavingsAccount.class); + when(accounts.findOneWithNotFoundDetection(42L)).thenReturn(account); + when(account.getCurrency()).thenReturn(new MonetaryCurrency("UGX", 2, 0)); + when(savings.withdrawal(eq(42L), any(JsonCommand.class))).thenAnswer(invocation -> { + JsonCommand flat = invocation.getArgument(1); + assertThat(flat.json()).doesNotContain("_serverCommand", "payload"); + return new CommandProcessingResultBuilder().withEntityId(42L).withSavingsId(42L).build(); + }); + when(resultSerializer.serializeResult(any())).thenReturn("{\"savingsId\":42}"); + authority("10", "100"); + when(repository.saveAndFlush(any(CommandSource.class))).thenAnswer(invocation -> { + savedSource = invocation.getArgument(0); + if (savedSource.getId() == null) { + savedSource.setId(101L); + } + // Snapshot now: the same entity is mutated again before the final save. + savedStates.add(savedSource.getStatusEnum()); + return savedSource; + }); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + ThreadLocalContextUtil.reset(); + applicationContext.close(); + } + + @ParameterizedTest + @CsvSource({ "9,false", "10,true", "50,true", "100,true", "101,false" }) + void inclusiveAuthorityBoundaries(String amount, boolean allowed) { + if (allowed) { + assertThat(commands.logCommandSource(withdrawal(amount, SavingsTransactionOrigin.STAFF_API)).getSavingsId()).isEqualTo(42L); + assertThat(savedSource.getStatusEnum()).isEqualTo(CommandProcessingResultType.PROCESSED); + } else { + assertDenied(() -> commands.logCommandSource(withdrawal(amount, SavingsTransactionOrigin.STAFF_API))); + verifyNoInteractions(savings); + } + verify(authorities).findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX"); + } + + @ParameterizedTest + @ValueSource(strings = { "missing", "unconfigured", "currencyMismatch", "invalid" }) + void unavailableOrInvalidAuthorityFailsClosed(String condition) { + switch (condition) { + case "unconfigured" -> authority(null, null); + case "invalid" -> { + var invalid = new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX", BigDecimal.TEN, + new BigDecimal("100")); + // Simulate an invalid legacy database row; normal construction rejects this range. + ReflectionTestUtils.setField(invalid, "minimumAmount", new BigDecimal("200")); + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX")) + .thenReturn(Optional.of(invalid)); + } + default -> { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX")) + .thenReturn(Optional.empty()); + if (condition.equals("currencyMismatch")) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "USD")) + .thenReturn(Optional.of(new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.WITHDRAWALS, "USD", + BigDecimal.ZERO, new BigDecimal("1000")))); + } + } + } + assertDenied(() -> commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.STAFF_API))); + verifyNoInteractions(savings); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unauthorizedSubmissionNeverQueues(boolean makerChecker) { + when(configuration.isMakerCheckerEnabledForTask(anyString())).thenReturn(makerChecker); + assertDenied(() -> commands.logCommandSource(withdrawal("101", SavingsTransactionOrigin.STAFF_API))); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.ERROR); + verifyNoInteractions(savings); + } + + @Test + void authorizedWithdrawalQueuesAndApprovalRetainsMakerOriginAndCheckerAudit() { + CommandSource pending = queue(); + assertThat(SavingsTransactionCommandEnvelope.decode(pending.getCommandAsJson(), SavingsTransactionKind.WITHDRAWAL).origin()) + .isEqualTo(SavingsTransactionOrigin.STAFF_API); + AppUser checker = checker(pending); + commands.approveEntry(101L); + assertThat(pending.getMaker()).isSameAs(user); + assertThat(pending.getChecker()).isSameAs(checker); + assertThat(pending.getStatusEnum()).isEqualTo(CommandProcessingResultType.PROCESSED); + verify(checker).validateHasCheckerPermissionTo("WITHDRAWAL_SAVINGSACCOUNT"); + verify(authorities, org.mockito.Mockito.times(2)).findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, + MonetaryAuthorityType.WITHDRAWALS, "UGX"); + verify(authorities, never()).findByAppUserIdAndAuthorityTypeAndCurrencyCode(eq(99L), any(), any()); + } + + @ParameterizedTest + @ValueSource(strings = { "reduced", "removed", "invalid" }) + void checkerCannotOverrideChangedMakerAuthority(String change) { + CommandSource pending = queue(); + checker(pending); + if (change.equals("reduced")) { + authority("10", "20"); + } else if (change.equals("removed")) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX")) + .thenReturn(Optional.empty()); + } else { + authority(null, null); + } + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.approveEntry(101L)); + assertThat(pending.getChecker()).isNull(); + verifyNoInteractions(savings); + } + + @Test + void higherAuthorityCheckerCannotApprovePreviouslyQueuedAboveLimitWithdrawal() { + CommandSource pending = queue(); + pending.setCommandAsJson(SavingsTransactionCommandEnvelope.encode(payload("101"), SavingsTransactionKind.WITHDRAWAL, + SavingsTransactionOrigin.STAFF_API)); + checker(pending); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.approveEntry(101L)); + verifyNoInteractions(savings); + } + + @Test + void importRequiresMakerAuthorityAndPreservesFlatPayload() { + assertDenied(() -> commands.logCommandSource(withdrawal("10000", SavingsTransactionOrigin.SPREADSHEET_IMPORT))); + verifyNoInteractions(savings); + newRequest(); + commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.SPREADSHEET_IMPORT)); + ArgumentCaptor command = ArgumentCaptor.forClass(JsonCommand.class); + verify(savings).withdrawal(eq(42L), command.capture()); + assertThat(command.getValue().bigDecimalValueOfParameterNamed("transactionAmount")).isEqualByComparingTo("50"); + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.NullSource + @ValueSource(strings = { "", " ", "{}", "not json", "null", "[]", + "{\"_serverCommand\":{\"version\":2,\"origin\":\"STAFF_API\"},\"payload\":{}}", + "{\"_serverCommand\":{\"version\":1,\"origin\":\"UNKNOWN\"},\"payload\":{}}" }) + void pendingLegacyOrUntrustedCommandsFailWithActionableDomainError(String json) { + CommandSource pending = queue(); + pending.setCommandAsJson(json); + checker(pending); + org.mockito.Mockito.clearInvocations(savings); + GeneralPlatformDomainRuleException exception = assertThrows(GeneralPlatformDomainRuleException.class, + () -> commands.approveEntry(101L)); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.withdrawal.untrusted.origin"); + assertThat(exception.getDefaultUserMessage()).contains("Cancel", "resubmit"); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(403); + verifyNoInteractions(savings); + } + + @Test + void reservedMetadataInjectionIsRejectedBeforePersistence() { + CommandWrapper injected = new CommandWrapperBuilder().savingsAccountWithdrawal(42L) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API) + .withJson("{\"_serverCommand\":{\"origin\":\"SPREADSHEET_IMPORT\"},\"transactionAmount\":10000}").build(); + var exception = assertThrows(GeneralPlatformDomainRuleException.class, () -> commands.logCommandSource(injected)); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.withdrawal.reserved.metadata"); + assertThat(savedStates).isEmpty(); + verifyNoInteractions(savings, authorities); + } + + @Test + void retryUsesStoredPayloadOriginAndMakerRatherThanReplacementRequest() { + CommandSource pending = queue(); + checker(pending); + pending.setStatus(CommandProcessingResultType.ERROR); + new FineractRequestContextHolder().setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, 101L); + authority("10", "20"); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.logCommandSource(withdrawal("10", SavingsTransactionOrigin.SPREADSHEET_IMPORT))); + verifyNoInteractions(savings); + assertThat(pending.getMaker()).isSameAs(user); + assertThat(SavingsTransactionCommandEnvelope.decode(pending.getCommandAsJson(), SavingsTransactionKind.WITHDRAWAL).origin()) + .isEqualTo(SavingsTransactionOrigin.STAFF_API); + } + + @Test + void rejectingLegacyCommandDoesNotRequireDecodingOrChangePayload() { + CommandSource pending = queue(); + pending.setCommandAsJson(payload("50")); + AppUser checker = checker(pending); + org.mockito.Mockito.clearInvocations(savings, authorities); + commands.rejectEntry(101L); + assertThat(pending.getCommandAsJson()).isEqualTo(payload("50")); + assertThat(pending.getChecker()).isSameAs(checker); + verifyNoInteractions(savings, authorities); + } + + @Test + void unrelatedSavingsCommandStorageAndMakerCheckerStayFlat() { + when(handlers.getHandler("SAVINGSACCOUNT", "ACTIVATE")).thenReturn(new ActivateSavingsAccountCommandHandler(savings)); + when(savings.activate(eq(42L), any())).thenReturn(new CommandProcessingResultBuilder().withSavingsId(42L).build()); + when(configuration.isMakerCheckerEnabledForTask("ACTIVATE_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, () -> commands.logCommandSource( + new CommandWrapperBuilder().savingsAccountActivation(42L).withJson("{\"dateFormat\":\"yyyy-MM-dd\"}").build())); + assertThat(savedSource.getCommandAsJson()).isEqualTo("{\"dateFormat\":\"yyyy-MM-dd\"}"); + assertThat(savedSource.isAwaitingApproval()).isTrue(); + verifyNoInteractions(authorities, accounts); + } + + @Test + void approvalRequestCannotReplacePersistedOrigin() { + CommandSource pending = queue(); + checker(pending); + authority("10", "20"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("origin", "SPREADSHEET_IMPORT"); + request.addParameter("origin", "SPREADSHEET_IMPORT"); + request.setContent("{\"_serverCommand\":{\"version\":1,\"origin\":\"SPREADSHEET_IMPORT\"}}".getBytes(UTF_8)); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + var resource = new MakercheckersApiResource(null, null, commands); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> resource.approveMakerCheckerEntry(101L, "approve")); + verifyNoInteractions(savings); + assertThat(SavingsTransactionCommandEnvelope.decode(pending.getCommandAsJson(), SavingsTransactionKind.WITHDRAWAL).origin()) + .isEqualTo(SavingsTransactionOrigin.STAFF_API); + } + + @Test + void importApprovalRechecksOriginalMaker() { + when(configuration.isMakerCheckerEnabledForTask("WITHDRAWAL_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, + () -> commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.SPREADSHEET_IMPORT))); + CommandSource pending = savedSource; + checker(pending); + authority("10", "20"); + org.mockito.Mockito.clearInvocations(savings); + assertDenied(() -> commands.approveEntry(101L)); + verifyNoInteractions(savings); + } + + @Test + void contextFreeHandlerInvocationAndMissingMakerFailClosed() { + var handler = new WithdrawSavingsAccountCommandHandler(savings, + new SavingsWithdrawalAuthorityService(new NsimbiMonetaryAuthorityPolicyService(authorities)), accounts, null); + assertThrows(GeneralPlatformDomainRuleException.class, () -> handler.processCommand(JsonCommand.from(payload("50")))); + assertThrows(GeneralPlatformDomainRuleException.class, + () -> handler.processTransaction(JsonCommand.from(payload("50")), new SavingsTransactionExecutionContext( + SavingsTransactionKind.WITHDRAWAL, SavingsTransactionOrigin.SPREADSHEET_IMPORT, null))); + verifyNoInteractions(savings, authorities); + } + + @ParameterizedTest + @ValueSource(strings = { "POSTINTEREST", "CALCULATEINTEREST", "GSIM", "TRANSFER", "CHARGE", "UNDO" }) + void excludedCommandsKeepTheirHandlersAndFlatStorage(String kind) { + var result = new CommandProcessingResultBuilder().withSavingsId(42L).build(); + CommandWrapper wrapper; + switch (kind) { + case "POSTINTEREST" -> { + when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new PostInterestSavingsAccountCommandHandler(savings)); + when(savings.postInterest(any(JsonCommand.class))).thenReturn(result); + wrapper = new CommandWrapperBuilder().savingsAccountInterestPosting(42L).build(); + } + case "CALCULATEINTEREST" -> { + when(handlers.getHandler("SAVINGSACCOUNT", kind)).thenReturn(new CalculateInterestSavingsAccountCommandHandler(savings)); + when(savings.calculateInterest(42L)).thenReturn(result); + wrapper = new CommandWrapperBuilder().savingsAccountInterestCalculation(42L).build(); + } + case "GSIM" -> { + when(handlers.getHandler("GSIMACCOUNT", "DEPOSIT")).thenReturn(new GSIMDepositCommandHandler(savings)); + when(savings.gsimDeposit(eq(42L), any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().gsimSavingsAccountDeposit(42L).build(); + } + case "UNDO" -> { + when(handlers.getHandler("SAVINGSACCOUNT", "UNDOTRANSACTION")) + .thenReturn(new UndoTransactionSavingsAccountCommandHandler(savings)); + when(savings.undoTransaction(42L, 7L, false)).thenReturn(result); + wrapper = new CommandWrapperBuilder().undoSavingsAccountTransaction(42L, 7L).build(); + } + case "CHARGE" -> { + when(handlers.getHandler("SAVINGSACCOUNTCHARGE", "PAY")).thenReturn(new PaySavingsAccountChargeCommandHandler(savings)); + when(savings.payCharge(eq(42L), eq(7L), any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().paySavingsAccountCharge(42L, 7L).build(); + } + default -> { + var transfers = mock(AccountTransfersWritePlatformService.class); + when(handlers.getHandler("ACCOUNTTRANSFER", "CREATE")).thenReturn(new CreateAccountTransferCommandHandler(transfers)); + when(transfers.create(any())).thenReturn(result); + wrapper = new CommandWrapperBuilder().createAccountTransfer().build(); + } + } + assertThat(commands.logCommandSource(wrapper)).isSameAs(result); + assertThat(savedSource.getCommandAsJson()).isEqualTo("{}"); + verifyNoInteractions(authorities, accounts); + } + + @ParameterizedTest + @ValueSource(strings = {"50", "101"}) + void forceWithdrawalRequiresAuthorityInAdditionToForcePermission(String amount) { + when(handlers.getHandler("SAVINGSACCOUNT", "FORCE_WITHDRAWAL")).thenReturn(new ForceWithdrawalSavingsAccountCommandHandler(savings, + new SavingsWithdrawalAuthorityService(new NsimbiMonetaryAuthorityPolicyService(authorities)), accounts, + new SavingsAccountTransactionDataValidator(new FromJsonHelper(), configuration, mock(BackdatedTransactionValidationService.class)))); + when(savings.forceWithdrawal(eq(42L), any())).thenReturn(new CommandProcessingResultBuilder().withSavingsId(42L).build()); + var wrapper = new CommandWrapperBuilder().savingsAccountForceWithdrawal(42L) + .withSavingsTransactionOrigin(SavingsTransactionOrigin.STAFF_API).withJson(payload(amount)).build(); + if (amount.equals("50")) { commands.logCommandSource(wrapper); } + else { assertDenied(() -> commands.logCommandSource(wrapper)); verifyNoInteractions(savings); } + verify(user).validateHasPermissionTo("FORCE_WITHDRAWAL_SAVINGSACCOUNT"); + } + + @Test + void asynchronousImportRestoresMakerAndChecksEveryRow() throws Exception { + ThreadLocalContextUtil.setBusinessDates(new java.util.HashMap<>( + Map.of(org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE, LocalDate.of(2026, 9, 25)))); + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.initialize(); + var securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken(user, null)); + SecurityContextHolder.setContext(securityContext); + var realSecurity = new SpringSecurityPlatformSecurityContext(configuration); + when(security.authenticatedUser(any(CommandWrapper.class))).thenAnswer(inv -> realSecurity.authenticatedUser(inv.getArgument(0))); + when(security.authenticatedUser()).thenAnswer(inv -> realSecurity.authenticatedUser()); + var document = mock(ImportDocument.class); + when(document.getEntityType()).thenReturn(SAVINGS_TRANSACTIONS.getValue()); + when(document.getDocumentId()).thenReturn(9L); + var documentRepository = mock(ImportDocumentRepository.class); + var pipe = mock(ContentPipe.class); + var dispatcher = mock(CommandDispatcher.class); + when(dispatcher.dispatch(any(DocumentUpdateCommand.class))).thenReturn(() -> null); + applicationContext.getBeanFactory().registerSingleton("savingsTransactionImportHandler", + new SavingsTransactionImportHandler(commands)); + var listener = new BulkImportEventListener(applicationContext, documentRepository, pipe, dispatcher); + var done = new CountDownLatch(1); + var multicaster = new SpringConfig().applicationEventMulticaster(executor); + multicaster.addApplicationListener((ApplicationListener) event -> { + try { + listener.onApplicationEvent(event); + } finally { + done.countDown(); + } + }); + try (var workbook = new HSSFWorkbook()) { + var sheet = workbook.createSheet(SAVINGS_TRANSACTION_SHEET_NAME); + var paymentType = workbook.createSheet(EXTRAS_SHEET_NAME).createRow(0); + paymentType.createCell(0).setCellValue(1); + paymentType.createCell(1).setCellValue("Cash"); + for (int i = 0; i <= STATUS_COL; i++) { + sheet.createRow(i); + } + for (int i = 1; i <= 2; i++) { + var row = sheet.getRow(i); + row.createCell(SAVINGS_ACCOUNT_NO_COL).setCellValue(42); + row.createCell(TRANSACTION_TYPE_COL).setCellValue("Withdrawal"); + row.createCell(PAYMENT_TYPE_COL).setCellValue("Cash"); + row.createCell(AMOUNT_COL).setCellValue(i == 1 ? 50 : 101); + row.createCell(TRANSACTION_DATE_COL).setCellValue(LocalDate.of(2026, 9, 24)); + } + multicaster.multicastEvent(new BulkImportEvent(this, workbook, "test.xls", "xls", document, "en", "yyyy-MM-dd", + ThreadLocalContextUtil.getContext(), 2L)); + assertThat(done.await(20, SECONDS)).isTrue(); + verify(document).update(any(), eq(1), eq(1)); + verify(documentRepository).saveAndFlush(document); + verify(savings).withdrawal(eq(42L), any()); + assertThat(savedSource.getMaker()).isSameAs(user); + assertThat(SavingsTransactionCommandEnvelope.decode(savedSource.getCommandAsJson(), SavingsTransactionKind.WITHDRAWAL).origin()) + .isEqualTo(SavingsTransactionOrigin.SPREADSHEET_IMPORT); + assertThat(sheet.getRow(1).getCell(STATUS_COL).getStringCellValue()).isEqualTo("Imported"); + assertThat(sheet.getRow(2).getCell(STATUS_COL).getStringCellValue()).contains("WITHDRAWALS monetary authority"); + verify(authorities, org.mockito.Mockito.times(2)).findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, + MonetaryAuthorityType.WITHDRAWALS, "UGX"); + } finally { + executor.shutdown(); + SecurityContextHolder.clearContext(); + } + } + + @Test + void completedReplayDoesNotReevaluateAuthorityOrRequireHistoricalMetadata() { + commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.STAFF_API)); + var completed = savedSource; + completed.setCommandAsJson(payload("50")); + when(repository.findByActionNameAndEntityNameAndIdempotencyKey("WITHDRAWAL", "SAVINGSACCOUNT", "withdrawal-test")) + .thenReturn(completed); + newRequest(); + org.mockito.Mockito.clearInvocations(savings, authorities); + assertThrows(IdempotentCommandProcessSucceedException.class, + () -> commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.STAFF_API))); + verifyNoInteractions(savings, authorities); + } + + private void assertDenied(org.junit.jupiter.api.function.Executable action) { + var exception = assertThrows(GeneralPlatformDomainRuleException.class, action); + assertThat(exception.getGlobalisationMessageCode()).isEqualTo("error.msg.savings.withdrawal.monetary.authority.denied"); + assertThat(errors.handle(exception).getStatusCode()).isEqualTo(403); + } + + private CommandSource queue() { + when(configuration.isMakerCheckerEnabledForTask("WITHDRAWAL_SAVINGSACCOUNT")).thenReturn(true); + assertThrows(RollbackTransactionNotApprovedException.class, () -> commands.logCommandSource(withdrawal("50", SavingsTransactionOrigin.STAFF_API))); + assertThat(savedStates).containsExactly(CommandProcessingResultType.UNDER_PROCESSING, CommandProcessingResultType.AWAITING_APPROVAL); + return savedSource; + } + + private AppUser checker(CommandSource pending) { + AppUser checker = mock(AppUser.class); + when(checker.getId()).thenReturn(99L); + when(security.authenticatedUser()).thenReturn(checker); + when(security.authenticatedUser(any(CommandWrapper.class))).thenReturn(checker); + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(99L, MonetaryAuthorityType.WITHDRAWALS, "UGX")) + .thenReturn(Optional.of(new NsimbiUserMonetaryAuthority(99L, MonetaryAuthorityType.WITHDRAWALS, "UGX", BigDecimal.ZERO, + new BigDecimal("100000")))); + when(repository.findById(101L)).thenReturn(Optional.of(pending)); + when(repository.findByActionNameAndEntityNameAndIdempotencyKey("WITHDRAWAL", "SAVINGSACCOUNT", "withdrawal-test")) + .thenReturn(pending); + newRequest(); + return checker; + } + + private void authority(String minimum, String maximum) { + when(authorities.findByAppUserIdAndAuthorityTypeAndCurrencyCode(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX")) + .thenReturn(Optional.of(new NsimbiUserMonetaryAuthority(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX", + minimum == null ? null : new BigDecimal(minimum), maximum == null ? null : new BigDecimal(maximum)))); + } + + private CommandWrapper withdrawal(String amount, SavingsTransactionOrigin origin) { + return new CommandWrapperBuilder().savingsAccountWithdrawal(42L).withSavingsTransactionOrigin(origin).withJson(payload(amount)) + .build(); + } + + private String payload(String amount) { + return "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\",\"transactionDate\":\"2026-09-24\",\"transactionAmount\":" + amount + + ",\"paymentTypeId\":1}"; + } + + private void newRequest() { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalChargeExemptionTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalChargeExemptionTest.java new file mode 100644 index 00000000000..1f5ea26049f --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/SavingsWithdrawalChargeExemptionTest.java @@ -0,0 +1,98 @@ +/** + * 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.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeDataValidator; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +class SavingsWithdrawalChargeExemptionTest { + + @AfterEach + void reset() { + ThreadLocalContextUtil.reset(); + MoneyHelper.clearCache(); + } + + @ParameterizedTest + @ValueSource(ints = { 100, 101 }) + void manualPaymentCannotExceedOutstandingCharge(int amount) { + ThreadLocalContextUtil + .setTenant(new org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant(1L, "test", "Test", "UTC", null)); + MoneyHelper.initializeTenantRoundingMode("test", 6); + var date = LocalDate.of(2026, 9, 25); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, date))); + var currency = new MonetaryCurrency("UGX", 2, 0); + var account = mock(SavingsAccount.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.doReturn(false).when(account).isClosed(); + org.mockito.Mockito.doReturn(false).when(account).isNotActive(); + var charge = mock(SavingsAccountCharge.class); + ReflectionTestUtils.setField(account, "currency", currency); + when(account.getCurrency()).thenReturn(currency); + when(account.getActivationDate()).thenReturn(date.minusDays(1)); + when(charge.getAmountOutstanding(currency)).thenReturn(Money.of(currency, new BigDecimal("100"))); + var expected = mock(SavingsAccountTransaction.class); + org.mockito.Mockito.doReturn(expected).when(account).payCharge(eq(charge), any(Money.class), eq(date), eq(false), isNull()); + if (amount == 101) { + assertThrows(PlatformApiDataValidationException.class, + () -> account.payCharge(charge, BigDecimal.valueOf(amount), date, DateTimeFormatter.ISO_LOCAL_DATE, false, null)); + verify(account, never()).payCharge(eq(charge), any(Money.class), any(), anyBoolean(), any()); + } else { + assertThat(account.payCharge(charge, BigDecimal.valueOf(amount), date, DateTimeFormatter.ISO_LOCAL_DATE, false, null)) + .isSameAs(expected); + } + } + + @ParameterizedTest + @ValueSource(strings = { "toAccountId", "beneficiary", "paymentTypeId", "withdrawBalance", "transferAmount" }) + void chargePayloadRejectsDestinationAndCashWithdrawalParameters(String parameter) { + var validator = new SavingsAccountChargeDataValidator(new FromJsonHelper()); + assertThrows(UnsupportedParameterException.class, () -> validator.validatePayCharge( + "{\"locale\":\"en\",\"amount\":50,\"dueDate\":\"2026-09-25\",\"dateFormat\":\"yyyy-MM-dd\",\"" + parameter + "\":1}")); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImplTest.java index bec066c5943..fd6e6d17a23 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImplTest.java @@ -39,6 +39,9 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; +import org.apache.fineract.commands.domain.SavingsTransactionKind; +import org.apache.fineract.commands.domain.SavingsTransactionOrigin; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -52,6 +55,8 @@ import org.apache.fineract.infrastructure.dataqueries.service.EntityDatatableChecksWritePlatformService; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.nsimbi.userroles.domain.MonetaryAuthorityType; +import org.apache.fineract.nsimbi.userroles.service.NsimbiMonetaryAuthorityPolicyService; import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; @@ -88,6 +93,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -155,6 +161,69 @@ class SavingsAccountWritePlatformServiceJpaRepositoryImplTest { @InjectMocks private SavingsAccountWritePlatformServiceJpaRepositoryImpl service; + private final NsimbiMonetaryAuthorityPolicyService monetaryAuthority = mock(NsimbiMonetaryAuthorityPolicyService.class); + + private SavingsTransactionExecutionContext execution(SavingsTransactionKind kind) { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "test", "Test", "UTC", null)); + MoneyHelper.initializeTenantRoundingMode("test", 6); + var maker = mock(AppUser.class); + when(maker.getId()).thenReturn(2L); + ReflectionTestUtils.setField(service, "withdrawalAuthority", new SavingsWithdrawalAuthorityService(monetaryAuthority)); + return new SavingsTransactionExecutionContext(kind, SavingsTransactionOrigin.STAFF_API, maker); + } + + @Test + void withdrawalAdjustmentChecksFullReplacementBeforeUndoAndFinancialWrites() { + var execution = execution(SavingsTransactionKind.ADJUSTTRANSACTION); + var transaction = mock(SavingsAccountTransaction.class); + when(transaction.isWithdrawal()).thenReturn(true); + when(savingsAccountTransactionRepository.findOneByIdAndSavingsAccountId(7L, 42L)).thenReturn(transaction); + var account = mock(SavingsAccount.class); + when(account.getCurrency()).thenReturn(new MonetaryCurrency("UGX", 2, 0)); + when(savingAccountAssembler.assembleFrom(42L, false)).thenReturn(account); + ThreadLocalContextUtil.setBusinessDates(new java.util.HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2026, 9, 25)))); + var command = businessCommand("{\"locale\":\"en\",\"transactionAmount\":150}"); + assertThatThrownBy(() -> service.adjustSavingsTransaction(42L, 7L, command, execution)) + .isInstanceOf(GeneralPlatformDomainRuleException.class); + verify(monetaryAuthority).allows(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX", new BigDecimal("150")); + verify(account, Mockito.never()).undoTransaction(anyLong()); + Mockito.verifyNoInteractions(paymentDetailWritePlatformService, savingsAccountDomainService, savingAccountRepositoryWrapper, + savingsAccountPostInterestService); + } + + @Test + void closureChecksActualBalanceBeforePaymentOrWithdrawal() { + var execution = execution(SavingsTransactionKind.CLOSE); + var account = mock(SavingsAccount.class, Mockito.RETURNS_DEEP_STUBS); + when(account.getCurrency()).thenReturn(new MonetaryCurrency("UGX", 2, 0)); + when(account.getSummary().getAccountBalance()).thenReturn(new BigDecimal("250")); + when(account.getSummary().getAccountBalance(account.getCurrency()).isGreaterThanZero()).thenReturn(true); + when(savingAccountAssembler.assembleFrom(42L, false)).thenReturn(account); + var command = businessCommand( + "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\",\"closedOnDate\":\"2026-09-25\",\"withdrawBalance\":true}"); + assertThatThrownBy(() -> service.close(42L, command, execution)).isInstanceOf(GeneralPlatformDomainRuleException.class); + verify(monetaryAuthority).allows(2L, MonetaryAuthorityType.WITHDRAWALS, "UGX", new BigDecimal("250")); + verify(account, Mockito.never()).close(any(), any()); + Mockito.verifyNoInteractions(paymentDetailWritePlatformService, savingsAccountDomainService, savingAccountRepositoryWrapper); + } + + @Test + void closureWithoutWithdrawalDoesNotInvokeAuthority() { + var execution = execution(SavingsTransactionKind.CLOSE); + var account = mock(SavingsAccount.class); + when(savingAccountAssembler.assembleFrom(42L, false)).thenReturn(account); + var command = businessCommand( + "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\",\"closedOnDate\":\"2026-09-25\",\"withdrawBalance\":false}"); + service.close(42L, command, execution); + Mockito.verifyNoInteractions(monetaryAuthority, paymentDetailWritePlatformService, savingsAccountDomainService); + } + + private JsonCommand businessCommand(String json) { + var helper = new org.apache.fineract.infrastructure.core.serialization.FromJsonHelper(); + return JsonCommand.from(json, helper.parse(json), helper, "SAVINGSACCOUNT", 42L, null, null, null, null, 42L, null, null, null, + null, null, null, ExternalId.empty()); + } + private Method validateTransactionsForTransfer; @BeforeEach diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java index c87778ccd3b..67af4d853f5 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java @@ -21,6 +21,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Set; +import org.apache.fineract.commands.domain.SavingsTransactionExecutionContext; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.organisation.office.domain.Office; @@ -50,6 +51,11 @@ CommandProcessingResult reverseTransaction(Long savingsId, Long transactionId, b CommandProcessingResult close(Long savingsId, JsonCommand command); + CommandProcessingResult close(Long savingsId, JsonCommand command, SavingsTransactionExecutionContext context); + + CommandProcessingResult adjustSavingsTransaction(Long savingsId, Long transactionId, JsonCommand command, + SavingsTransactionExecutionContext context); + SavingsAccountTransaction initiateSavingsTransfer(SavingsAccount account, LocalDate transferDate); SavingsAccountTransaction withdrawSavingsTransfer(SavingsAccount account, LocalDate transferDate); @@ -118,6 +124,8 @@ SavingsAccountData postInterest(SavingsAccountData account, boolean postInterest CommandProcessingResult gsimDeposit(Long gsimId, JsonCommand command); + CommandProcessingResult bulkGSIMClose(Long gsimId, JsonCommand command, SavingsTransactionExecutionContext context); + CommandProcessingResult bulkGSIMClose(Long gsimId, JsonCommand command); CommandProcessingResult forceWithdrawal(Long savingsId, JsonCommand command);