Compare commits

...
Author SHA1 Message Date
anshul 08519da8b1 feat: implement maker-checker workflow for user creation and update requests
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-27 17:51:22 +05:30
Anshul Rathore cad7f54c47 feat: implement maker-checker for product creation, add app user preferences, and introduce base interest rate management services.
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-26 10:38:09 +05:30
nitesh 8724c54d9b Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
2026-08-19 10:50:25 +05:30
nitesh 5c0a482f49 feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable
Every mobile-number lookup was calling out to a local mock HTTP service
on port 8090 that had to be started separately, so the check failed
with a connection error whenever that process wasn't running.

Ports the mock's full verdict table (suspected, disconnected,
suspected+disconnected, clean) into MobileLookupServiceImpl itself, so
the demo/test numbers resolve in-process with no HTTP call. Adds
fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL
call entirely for local work; every number then resolves CLEAN and is
stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken
for a genuine pass. Must be turned back on before UAT/production.
2026-08-19 10:35:48 +05:30
nitesh 0e07e60328 feat(current-accounts): add document upload/list/download/delete endpoints
/v1/currentaccounts/{id}/documents had no handler at all -- the
account's own /v1/currentaccounts resource matches the request path
first and has no documents method, so it never fell through to the
generic /v1/{entityType}/{entityId}/documents resource that every
other entity type relies on. Current accounts got a 404 on every
Documents-tab request.

Delegates straight to the existing DocumentApiResource so current
accounts get identical behaviour to clients, loans, etc.
2026-08-19 10:35:35 +05:30
nitesh e227e8ae0f fix(savings): post GL entries for facility-charge collections at edit approval
Charges collected when a maker-checker Nominee/Digital-Feature edit is
approved deducted the account balance and appeared in the passbook,
but never posted a journal entry -- collectCharges() saved the charge
transaction directly and skipped the accounting bridge entirely, so
the GL never balanced against the subledger.

Also fixes an unrelated NPE this exposed: collectCharges() used
saveAll() instead of saveAllAndFlush(), so the new charge transaction
had no id yet when the journal-entry code tried to read it.
2026-08-19 10:35:18 +05:30
nitesh 231919b62b fix(savings): stop crediting accounts automatically on activation
Activation for Savings, RD, FD and Current accounts no longer books an
opening deposit or enforces a minimum opening balance -- accounts go
active at zero balance and a clerk deposits funds afterward as a
separate transaction, matching how these branches actually operate.

Fixed Deposit and Recurring Deposit have their own override of
activateWithBalance() that a prior fix to the shared SavingsAccount
base class missed, so they kept auto-depositing after Savings was
already fixed. Removing that auto-deposit exposed two follow-on bugs
in the FD/RD activation flow, now also fixed:
- the opening deposit's journal entries were posted twice (once by the
  deposit helper, once by the activation command's own posting call)
- FD/RD maturity amount was computed from the account's real (now
  empty) transaction history at activation and never recalculated once
  a deposit actually landed, so it showed no projected interest until
  the account was reloaded from a lucky code path
2026-08-19 10:34:54 +05:30
64 changed files with 3713 additions and 229 deletions
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode(); String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) { if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || user.isCheckerSuperUser()) { if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
commandSource.markAsChecked(user); commandSource.markAsChecked(user);
} else { } else {
if (commandSource.isSanitized()) { if (commandSource.isSanitized()) {
@@ -151,6 +151,25 @@ public class CommandSourceService {
return result; return result;
} }
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) { private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return; return;
@@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder {
return this; return this;
} }
public CommandWrapperBuilder createCbsBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSBASEINTERESTRATE";
this.entityId = null;
this.href = "/fixeddepositproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) { public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE"; this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT"; this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode); boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
boolean isMakerCheckerEnabled();
/** /**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least * Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge * one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return false; return false;
} }
@Override
public boolean isMakerCheckerEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
}
@Override @Override
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() { public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -57,14 +57,23 @@ public class MobileLookupServiceImpl implements MobileLookupService {
"9752123456", "9752345678", "9752987654" "9752123456", "9752345678", "9752987654"
); );
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
private final String baseUrl; private final String baseUrl;
private final boolean enabled;
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository; private final MobileLookupLogRepository logRepository;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl, public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) { MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.enabled = enabled;
this.restTemplate = new RestTemplate(); this.restTemplate = new RestTemplate();
this.logRepository = logRepository; this.logRepository = logRepository;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
@@ -103,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return MobileLookupResult.clean(logId, trimmed); return MobileLookupResult.clean(logId, trimmed);
} }
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
if (isDemoDisconnectedNumber(trimmed)) {
LOG.info("Demo disconnected number detected: {}", trimmed);
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
}
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
"active", "Demo DIP suspected number", "DEMO");
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
}
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
// CLEAN result so it can never be mistaken for one in an audit trail.
if (!enabled) {
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
return MobileLookupResult.clean(logId, trimmed);
}
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed; final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
LOG.info("Calling DIP/MNRL lookup API: {}", url); LOG.info("Calling DIP/MNRL lookup API: {}", url);
@@ -402,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return false; return false;
} }
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private String getStringOrNull(JsonObject json, String key) { private String getStringOrNull(JsonObject json, String key) {
JsonElement el = json.get(key); JsonElement el = json.get(key);
return (el != null && !el.isJsonNull()) ? el.getAsString() : null; return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
@@ -18,6 +18,11 @@
*/ */
package org.apache.fineract.portfolio.savings.api; package org.apache.fineract.portfolio.savings.api;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT; import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import java.io.InputStream;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator; import org.apache.fineract.infrastructure.security.service.SqlValidator;
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService; import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/currentaccounts") @Path("/v1/currentaccounts")
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService; private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer; private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
private final DocumentApiResource documentApiResource;
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
@GET @GET
@Path("template") @Path("template")
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
// ── Documents ────────────────────────────────────────────────────────────
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
// is matched first for anything under {accountId}, and it has no "documents" method, so the
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
// DocumentApiResource so current accounts get identical document-management behaviour.
@GET
@Path("{accountId}/documents")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
}
@GET
@Path("{accountId}/documents/{documentId}/attachment")
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Download a document attached to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
@POST
@Path("{accountId}/documents")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Attach a document to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
description);
}
@DELETE
@Path("{accountId}/documents/{documentId}")
@Operation(summary = "Remove a document attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
@PathParam("documentId") final Long documentId) {
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
} }
@@ -29,6 +29,10 @@ import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
@@ -42,6 +46,7 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder() private final Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class, .registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString())) (JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
@@ -77,11 +82,11 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.") @Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) { public CommandProcessingResult saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME); final CommandWrapper commandRequest = new CommandWrapperBuilder()
final FdInterestRateSaveRequest request = gson.fromJson(apiRequestBodyAsJson, FdInterestRateSaveRequest.class); .createCbsBaseInterestRate()
final Long createdById = this.context.authenticatedUser().getId(); .withJson(apiRequestBodyAsJson)
final FdInterestRateEffectiveData saved = this.fdBaseInterestRateReadPlatformService.saveInterestRates(request, createdById); .build();
return gson.toJson(saved); return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} }
} }
@@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount {
@Override @Override
public Money activateWithBalance() { public Money activateWithBalance() {
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount()); // Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of the
// deposit amount configured on the account's term/pre-closure details.
return Money.zero(this.currency);
} }
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) { public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
@Override @Override
public Money activateWithBalance() { public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance); // Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
} }
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals, protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
@@ -19,7 +19,6 @@
package org.apache.fineract.portfolio.savings.domain; package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element)); account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element)); account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
// Account Facility Config // Every digital facility starts disabled at account opening, regardless of anything the
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) { // request sends or the product defaults to -- they're switched on deliberately, one at a
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName); // time, later, through the account's own Edit Nominee & Features screen.
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) { account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
if (facilityConfig == null) {
facilityConfig = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(facilityConfig);
}
facilityConfig.updateFrom(configEl.getAsJsonObject());
}
}
// Joint holders // Joint holders
if (element.getAsJsonObject().has(jointHoldersParamName)) { if (element.getAsJsonObject().has(jointHoldersParamName)) {
@@ -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.portfolio.savings.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final FdBaseInterestRateWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveInterestRates(command);
}
}
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId); "Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
} }
// Minimum required balance validation // Minimum required balance is not enforced -- a withdrawal must never be blocked for
// taking the account below its configured minimum, only for exceeding the available
// balance (checked above).
final BigDecimal newBalance = currentBalance.subtract(amount); final BigDecimal newBalance = currentBalance.subtract(amount);
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
throw new GeneralPlatformDomainRuleException(
"error.msg.current.account.min.required.balance.breached",
"Withdrawal of " + amount + " would breach the minimum required balance of "
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
accountId);
}
}
final String refNo = command.stringValueOfParameterNamed("accountNumber"); final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo); final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
} }
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig" // Every digital facility starts disabled at account opening, regardless of anything the
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig"); // request sends or the product defaults to -- they're switched on deliberately, one at a
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig"); // time, later, through the account's own Edit Nominee & Features screen.
if (fcJson != null) { account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
}
// Joint holders // Joint holders
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final JsonObject json = command.parsedJson().getAsJsonObject(); final JsonObject json = command.parsedJson().getAsJsonObject();
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// Validate minimum opening balance before activation
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
// intentionally ignored
}
}
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
&& openingBalanceValue.compareTo(minRequired) < 0) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
"validation.msg.currentaccount.openingBalance.less.than.min.required",
openingBalanceValue, minRequired)));
}
account.activate(activatedOnDate, user); account.activate(activatedOnDate, user);
applyFacilityChargesOnActivation(account);
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO; // Activation posts no transaction of its own -- no opening-balance deposit, no
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>(); // digital-facility charges applied or collected, regardless of what the account's facility
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>(); // config or product defaults say. Funding and any facility charges happen strictly
// afterwards, through separate transactions a clerk processes once the account is already
// Opening balance deposit transaction // active.
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
try {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
deposit.setRunningBalanceDerived(openingBalanceValue);
account.setTotalDepositsDerived(openingBalanceValue);
currentAccountTransactionRepository.saveAndFlush(deposit);
currentBalance = openingBalanceValue;
depositTxns.add(deposit);
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
} catch (Exception e) {
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
}
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
account.getCharges().stream()
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
.toList();
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
account.setAccountBalanceDerived(currentBalance);
currentAccountRepository.saveAndFlush(account); currentAccountRepository.saveAndFlush(account);
// Post GL journal entries — deposits in batch, each charge individually
postGlEntries(account, depositTxns, chargeEntries);
return new CommandProcessingResultBuilder().withEntityId(accountId).build(); return new CommandProcessingResultBuilder().withEntityId(accountId).build();
} }
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
} }
} }
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
// Use account-level facility config — reflects what was selected at account opening time
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
account.getFacilityConfig();
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
account.getId(), config != null ? "FOUND" : "NULL");
if (config == null) {
// Fallback: use product-level facility config
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
config = currentProductRepository.findById(account.getProductId())
.map(p -> {
final var pc = p.getFacilityConfig();
if (pc == null) {
log.info("[FacilityCharge] Product has no facility config either");
return null;
}
// Map product config to account config fields inline
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
return ac;
}).orElse(null);
}
if (config == null) {
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
return;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
// Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
// the facility enabled.
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
}
// Wrapper to carry a charge transaction together with its charge entity for GL posting // Wrapper to carry a charge transaction together with its charge entity for GL posting
private record ChargeGlEntry( private record ChargeGlEntry(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
/** /**
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the * activation itself no longer collects any charges. Returns the resulting balance.
* charge-collection logic isn't duplicated. Returns the resulting balance.
*/ */
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges, final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
} }
} }
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
return;
}
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
return;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
return;
}
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
account.getCharges().add(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
.createFromFacility(account, charge));
}
} }
@@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (portfolioAccountData == null) { if (portfolioAccountData == null) {
final PaymentDetail paymentDetail = null; final PaymentDetail paymentDetail = null;
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(), final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
amountForDeposit.getAmount(), paymentDetail); account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
// handleFDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes, e.g. an activation charge payment) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else { } else {
final SavingsAccount fromSavingsAccount = null; final SavingsAccount fromSavingsAccount = null;
boolean isRegularTransaction = false; boolean isRegularTransaction = false;
@@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
} }
final boolean isPreMatureClosure = false; final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, if (amountForDeposit.isGreaterThanZero()) {
financialYearBeginningMonth); account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
} else {
// No deposit has actually landed on the account yet -- project maturity using the
// configured deposit amount (the same approach used for the pre-activation preview and
// for reinvested FDs) instead of the account's real, still-empty transaction history,
// which would otherwise price in zero interest.
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null; List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) { if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
@@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
.retriveSavingsLinkedAssociation(savingsId); .retriveSavingsLinkedAssociation(savingsId);
if (portfolioAccountData == null) { if (portfolioAccountData == null) {
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(), final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
amountForDeposit.getAmount(), null, isRegularTransaction); account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
// handleRDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else { } else {
final boolean isExceptionForBalanceCheck = false; final boolean isExceptionForBalanceCheck = false;
final SavingsAccount fromSavingsAccount = null; final SavingsAccount fromSavingsAccount = null;
@@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate, final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail); transactionAmount, paymentDetail);
// The account may have been activated with no funds yet (opening deposit is now a separate,
// later step) -- in that case its maturity amount was only ever projected off the configured
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
// maturity date/amount off the account's real transaction history so it reflects reality.
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(), this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate, account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT"); "DEPOSIT");
@@ -11,10 +11,16 @@
*/ */
package org.apache.fineract.portfolio.savings.service; package org.apache.fineract.portfolio.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest; import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
@@ -51,7 +57,9 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
@Override @Override
@Transactional @Transactional
public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) { public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) {
final String fdType = request.getFdType(); validateRequest(request);
final String fdType = request.getFdType().toUpperCase();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository
@@ -85,6 +93,125 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
return toData(newVersion); return toData(newVersion);
} }
private void validateRequest(final FdInterestRateSaveRequest request) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (request == null) {
validator.failWithCodeNoParameterAddedToErrorCode("request.cannot.be.null");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final String fdType = request.getFdType();
if (fdType == null || fdType.isBlank()) {
validator.parameter("fdType").failWithCode("cannot.be.blank");
} else if (!List.of("SINGLE", "DOUBLE", "SPECIAL").contains(fdType.toUpperCase())) {
validator.parameter("fdType").value(fdType).failWithCode("invalid.fd.type");
}
if (request.getSlabs() == null || request.getSlabs().isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
}
if (request.getVersionLabel() != null && request.getVersionLabel().length() > 100) {
validator.parameter("versionLabel").value(request.getVersionLabel()).failWithCode("exceeds.max.length");
}
throwExceptionIfValidationWarningsExist(errors);
validateSlabs(request.getSlabs());
}
private void validateSlabs(final List<FdInterestRateSaveRequest.SlabRequest> slabs) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
if (slabs == null || slabs.isEmpty()) {
validator.parameter("slabs").failWithCode("cannot.be.blank");
throwExceptionIfValidationWarningsExist(errors);
return;
}
final List<FdInterestRateSaveRequest.SlabRequest> sorted = new ArrayList<>(slabs);
sorted.sort(Comparator.comparing((FdInterestRateSaveRequest.SlabRequest slab) -> slab.getSrNo() == null ? Integer.MAX_VALUE : slab.getSrNo())
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
final Integer expectedPeriodType = sorted.get(0).getPeriodTypeEnum();
Integer expectedFrom = 1;
for (int i = 0; i < sorted.size(); i++) {
final FdInterestRateSaveRequest.SlabRequest slab = sorted.get(i);
final int srNo = slab.getSrNo() == null ? i + 1 : slab.getSrNo();
final String slabPrefix = "slabs[" + srNo + "].";
if (slab.getPeriodTypeEnum() == null) {
validator.parameter(slabPrefix + "periodTypeEnum").failWithCode("cannot.be.blank");
} else if (!slab.getPeriodTypeEnum().equals(expectedPeriodType)) {
validator.parameter(slabPrefix + "periodTypeEnum").value(slab.getPeriodTypeEnum())
.failWithCode("must.be.consistent.across.all.slabs");
}
final Integer fromPeriod = slab.getFromPeriod();
if (fromPeriod == null) {
validator.parameter(slabPrefix + "fromPeriod").failWithCode("cannot.be.blank");
} else if (fromPeriod < 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.non.negative");
} else if (!fromPeriod.equals(expectedFrom)) {
if (i == 0) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("first.slab.must.start.at.one");
} else if (fromPeriod < expectedFrom) {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.not.overlap.previous.range");
} else {
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.continuous.with.previous.range");
}
}
final Integer toPeriod = slab.getToPeriod();
if (i < sorted.size() - 1) {
if (toPeriod == null) {
validator.parameter(slabPrefix + "toPeriod").failWithCode("cannot.be.blank.for.intermediate.slab");
} else {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
expectedFrom = toPeriod + 1;
}
} else {
if (toPeriod != null) {
if (toPeriod < 0) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
}
if (fromPeriod != null && toPeriod < fromPeriod) {
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
}
}
}
final BigDecimal annualRate = slab.getAnnualInterestRate();
if (annualRate == null) {
validator.parameter(slabPrefix + "annualInterestRate").failWithCode("cannot.be.blank");
} else if (annualRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "annualInterestRate").value(annualRate).failWithCode("must.be.non.negative");
}
final BigDecimal seniorRate = slab.getSeniorCitizenRate();
if (seniorRate != null && seniorRate.compareTo(BigDecimal.ZERO) < 0) {
validator.parameter(slabPrefix + "seniorCitizenRate").value(seniorRate).failWithCode("must.be.non.negative");
}
}
throwExceptionIfValidationWarningsExist(errors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) { private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) {
List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream() List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream()
.map(s -> FdInterestRateSlabData.builder() .map(s -> FdInterestRateSlabData.builder()
@@ -0,0 +1,27 @@
/**
* 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 org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface FdBaseInterestRateWritePlatformService {
CommandProcessingResult saveInterestRates(JsonCommand command);
}
@@ -0,0 +1,49 @@
/**
* 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 com.google.gson.Gson;
import lombok.RequiredArgsConstructor;
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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FdBaseInterestRateWritePlatformServiceImpl implements FdBaseInterestRateWritePlatformService {
private final PlatformSecurityContext context;
private final FdBaseInterestRateReadPlatformService readPlatformService;
private final Gson gson = new Gson();
@Override
public CommandProcessingResult saveInterestRates(final JsonCommand command) {
final FdInterestRateSaveRequest request = gson.fromJson(command.json(), FdInterestRateSaveRequest.class);
final Long createdById = this.context.authenticatedUser().getId();
final FdInterestRateEffectiveData saved = this.readPlatformService.saveInterestRates(request, createdById);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withEntityId(saved.getId())
.build();
}
}
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId()); this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
} }
// 2) Post the real charge(s) for newly-enabled facilities, today. // 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
// GL journal entries for those charge transactions (collectCharges() only books them
// against the account itself; it does not touch the general ledger).
if (!newlyEnabledFacilityTypes.isEmpty()) { if (!newlyEnabledFacilityTypes.isEmpty()) {
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes); final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate()); this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
existingReversedTransactionIds, false);
} }
// 3) Apply the facility-config toggles to the real row. // 3) Apply the facility-config toggles to the real row.
@@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final GSIMRepositoy gsimRepository; private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler; private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final DenominationBreakdownWriter denominationBreakdownWriter; private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional @Transactional
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Validate minimum opening balance // Minimum required opening balance is not enforced -- activation must never be blocked
BigDecimal openingBalanceForValidation = BigDecimal.ZERO; // for not meeting it; the account can be funded afterwards by whatever deposit the
if (command.parameterExists("openingBalance")) { // customer brings (cash or a transfer), on their own timeline.
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
if (ob != null) openingBalanceForValidation = ob;
}
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
final List<ApiParameterError> errors = new ArrayList<>();
errors.add(ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
openingBalanceForValidation, minRequiredOpening));
throw new PlatformApiDataValidationException(errors);
}
checkClientOrGroupActive(account); checkClientOrGroupActive(account);
final Set<Long> existingTransactionIds = new HashSet<>(); final Set<Long> existingTransactionIds = new HashSet<>();
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale(); final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds); processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
// Auto-apply facility charges based on product facility config // Activation posts no transaction of its own -- digital-facility charges are no longer
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account); // auto-applied and auto-collected here. Whatever facilities are turned on later get
this.savingAccountRepositoryWrapper.saveAndFlush(account); // charged (if at all) through the normal charge/collection flow at that time, not as a
// Collect any newly added facility activation charges (after flush so amountOutstanding is set) // side effect of activation.
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
facilityChargeAutoApplyService.collectActivationCharges(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
} }
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData); this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
} }
@Override
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
}
@Override @Override
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) { public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
@@ -169,6 +169,8 @@ import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatf
import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster;
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask;
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService; import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService;
@@ -200,6 +202,14 @@ public class SavingsConfiguration {
return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository); return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository);
} }
@Bean
@ConditionalOnMissingBean(FdBaseInterestRateWritePlatformService.class)
public FdBaseInterestRateWritePlatformService fdBaseInterestRateWritePlatformService(
PlatformSecurityContext context,
FdBaseInterestRateReadPlatformService readPlatformService) {
return new FdBaseInterestRateWritePlatformServiceImpl(context, readPlatformService);
}
@Bean @Bean
@ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class) @ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class)
public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context, public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context,
@@ -463,8 +473,7 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService, ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -472,7 +481,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter); errorHandler, denominationBreakdownWriter);
} }
@Bean @Bean
@@ -0,0 +1,79 @@
package org.apache.fineract.useradministration.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.data.AppUserPreferenceData;
import org.apache.fineract.useradministration.service.AppUserPreferenceReadPlatformService;
import org.apache.fineract.useradministration.service.AppUserPreferenceWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/users/{userId}/preferences")
@Component
@Tag(name = "User Preferences", description = "API to manage user specific preferences.")
@RequiredArgsConstructor
public class AppUserPreferencesApiResource {
private final PlatformSecurityContext context;
private final AppUserPreferenceReadPlatformService readPlatformService;
private final AppUserPreferenceWritePlatformService writePlatformService;
private void validateUserAuthorization(Long userId) {
if (!context.authenticatedUser().getId().equals(userId)) {
throw new NoAuthorizationException("Not authorized to access or modify preferences for another user.");
}
}
@GET
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve user preference", description = "Retrieves a specific preference for the user.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) })
public AppUserPreferenceData getPreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName) {
validateUserAuthorization(userId);
String value = readPlatformService.getPreferenceValue(userId, preferenceName);
return new AppUserPreferenceData(preferenceName, value);
}
@PUT
@Path("{preferenceName}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update user preference", description = "Updates or creates a specific preference for the user.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class)))
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public AppUserPreferenceData updatePreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName,
final AppUserPreferenceData preferenceData) {
validateUserAuthorization(userId);
// Ensure the path parameter matches the request body if the request body contains a name
String valueToSave = preferenceData != null ? preferenceData.getValue() : null;
if (valueToSave == null) {
throw new IllegalArgumentException("Preference value cannot be null");
}
writePlatformService.updatePreference(userId, preferenceName, valueToSave);
return new AppUserPreferenceData(preferenceName, valueToSave);
}
}
@@ -88,6 +88,11 @@ public class UsersApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
private final BulkImportWorkbookService bulkImportWorkbookService; private final BulkImportWorkbookService bulkImportWorkbookService;
private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService;
private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService;
private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper;
private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService;
private final DefaultToApiJsonSerializer<org.apache.fineract.useradministration.data.UserPendingRequestData> pendingRequestApiJsonSerializer;
@GET @GET
@Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n" @Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n"
@@ -158,6 +163,12 @@ public class UsersApiResource {
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() // final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createUser() // .createUser() //
@@ -179,6 +190,12 @@ public class UsersApiResource {
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId, public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) { @Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.configurationDomainService.isMakerCheckerEnabled()) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command);
return this.toApiJsonSerializer.serialize(result);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() // final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateUser(userId) // .updateUser(userId) //
@@ -190,6 +207,56 @@ public class UsersApiResource {
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List User creations & edits pending approval")
public org.apache.fineract.infrastructure.core.service.Page<org.apache.fineract.useradministration.data.UserPendingRequestData> retrieveAllPendingUsers(
@QueryParam("status") final String status,
@QueryParam("username") final String username,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate,
@QueryParam("offset") final Integer offset,
@QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit);
}
@GET
@Path("pending/{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a pending user request by ID")
public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingRequestApiJsonSerializer.serialize(settings, data);
}
@POST
@Path("pending/{id}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending User creation or update")
public String approvePendingUser(@PathParam("id") final Long id) {
final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("pending/{id}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending User creation or update")
public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command);
return this.toApiJsonSerializer.serialize(result);
}
@POST @POST
@Path("{userId}/pwd") @Path("{userId}/pwd")
@Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.") @Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.")
@@ -0,0 +1,13 @@
package org.apache.fineract.useradministration.data;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppUserPreferenceData {
private String name;
private String value;
}
@@ -0,0 +1,59 @@
/**
* 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.useradministration.data;
import java.io.Serializable;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class UserPendingRequestData implements Serializable {
private static final long serialVersionUID = 1L;
private final Long id;
private final Long userId;
private final String requestType;
private final String status;
private final String username;
private final String firstname;
private final String lastname;
private final String email;
private final String mobileNo;
private final Long officeId;
private final String officeName;
private final Long staffId;
private final String staffName;
private final String otpDeliveryMethod;
private final Boolean passwordNeverExpires;
private final Collection<String> roleNames;
private final Long makerId;
private final String makerUsername;
private final OffsetDateTime submittedDate;
private final Long checkerId;
private final String checkerUsername;
private final OffsetDateTime checkedDate;
private final String rejectReason;
private final Map<String, Object> originalValues;
private final Map<String, Object> proposedValues;
}
@@ -0,0 +1,54 @@
package org.apache.fineract.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_appuser_preference")
public class AppUserPreference extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "value", nullable = false)
private String value;
protected AppUserPreference() {
//
}
private AppUserPreference(final AppUser appUser, final String name, final String value) {
this.appUser = appUser;
this.name = name;
this.value = value;
}
public static AppUserPreference create(final AppUser appUser, final String name, final String value) {
return new AppUserPreference(appUser, name, value);
}
public void updateValue(final String newValue) {
this.value = newValue;
}
public String getName() {
return this.name;
}
public String getValue() {
return this.value;
}
public AppUser getAppUser() {
return this.appUser;
}
}
@@ -0,0 +1,10 @@
package org.apache.fineract.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AppUserPreferenceRepository extends JpaRepository<AppUserPreference, Long> {
Optional<AppUserPreference> findByAppUserIdAndName(Long appUserId, String name);
}
@@ -0,0 +1,113 @@
/**
* 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.useradministration.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_user_pending_request")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class UserPendingRequest extends AbstractPersistableCustom<Long> {
@Column(name = "user_id")
private Long userId;
@Column(name = "request_type", nullable = false, length = 20)
private String requestType;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "payload_json", nullable = false)
private String payloadJson;
@Column(name = "original_json")
private String originalJson;
@Column(name = "password_hash")
private String passwordHash;
@Column(name = "maker_id", nullable = false)
private Long makerId;
@Column(name = "submitted_date", nullable = false)
private OffsetDateTime submittedDate;
@Column(name = "checker_id")
private Long checkerId;
@Column(name = "checked_date")
private OffsetDateTime checkedDate;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Column(name = "pending_lock_key", length = 100, unique = true)
private String pendingLockKey;
public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId,
final String payloadJson, final String originalJson, final String passwordHash,
final Long makerId, final String pendingLockKey) {
final UserPendingRequest request = new UserPendingRequest();
request.requestType = requestType.name();
request.userId = userId;
request.status = UserPendingRequestStatus.PENDING.name();
request.payloadJson = payloadJson;
request.originalJson = originalJson;
request.passwordHash = passwordHash;
request.makerId = makerId;
request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC);
request.pendingLockKey = pendingLockKey;
return request;
}
public boolean isPending() {
return UserPendingRequestStatus.PENDING.name().equals(this.status);
}
public boolean isMadeBy(final Long appUserId) {
return this.makerId != null && this.makerId.equals(appUserId);
}
public void approve(final Long checkerId) {
this.status = UserPendingRequestStatus.APPROVED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.pendingLockKey = null;
}
public void reject(final Long checkerId, final String reason) {
this.status = UserPendingRequestStatus.REJECTED.name();
this.checkerId = checkerId;
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -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.useradministration.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
boolean existsByPendingLockKey(String pendingLockKey);
}
@@ -0,0 +1,49 @@
/**
* 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.useradministration.domain;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingRequestRepositoryWrapper {
private final UserPendingRequestRepository repository;
@Transactional(readOnly = true)
public UserPendingRequest findOneWithNotFoundDetection(final Long id) {
return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id));
}
public UserPendingRequest saveAndFlush(final UserPendingRequest request) {
return this.repository.saveAndFlush(request);
}
public UserPendingRequest save(final UserPendingRequest request) {
return this.repository.save(request);
}
@Transactional(readOnly = true)
public boolean existsByPendingLockKey(final String pendingLockKey) {
return this.repository.existsByPendingLockKey(pendingLockKey);
}
}
@@ -0,0 +1,37 @@
/**
* 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.useradministration.domain;
public enum UserPendingRequestStatus {
PENDING,
APPROVED,
REJECTED;
public static UserPendingRequestStatus fromString(final String status) {
if (status == null) {
return null;
}
for (final UserPendingRequestStatus requestStatus : values()) {
if (requestStatus.name().equalsIgnoreCase(status)) {
return requestStatus;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.domain;
public enum UserPendingRequestType {
CREATE_USER,
UPDATE_USER;
public static UserPendingRequestType fromString(final String type) {
if (type == null) {
return null;
}
for (final UserPendingRequestType requestType : values()) {
if (requestType.name().equalsIgnoreCase(type)) {
return requestType;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
}
}
@@ -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.useradministration.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserPendingRequestNotFoundException(final Long id) {
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,5 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceReadPlatformService {
String getPreferenceValue(Long appUserId, String name);
}
@@ -0,0 +1,20 @@
package org.apache.fineract.useradministration.service;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceReadPlatformServiceImpl implements AppUserPreferenceReadPlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
@Override
public String getPreferenceValue(Long appUserId, String name) {
Optional<AppUserPreference> pref = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name);
return pref.map(AppUserPreference::getValue).orElse(null);
}
}
@@ -0,0 +1,5 @@
package org.apache.fineract.useradministration.service;
public interface AppUserPreferenceWritePlatformService {
void updatePreference(Long appUserId, String name, String value);
}
@@ -0,0 +1,32 @@
package org.apache.fineract.useradministration.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserPreference;
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AppUserPreferenceWritePlatformServiceImpl implements AppUserPreferenceWritePlatformService {
private final AppUserPreferenceRepository appUserPreferenceRepository;
private final AppUserRepository appUserRepository;
@Transactional
@Override
public void updatePreference(Long appUserId, String name, String value) {
AppUserPreference preference = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name).orElse(null);
if (preference == null) {
AppUser appUser = appUserRepository.findById(appUserId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.id.invalid", "User not found"));
preference = AppUserPreference.create(appUser, name, value);
} else {
preference.updateValue(value);
}
appUserPreferenceRepository.saveAndFlush(preference);
}
}
@@ -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.useradministration.service;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
public interface UserPendingReadPlatformService {
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
Integer offset, Integer limit);
UserPendingRequestData retrievePendingRequest(Long id);
}
@@ -0,0 +1,329 @@
/**
* 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.useradministration.service;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private static final String SELECT_SQL = """
SELECT r.id AS id,
r.user_id AS userId,
r.request_type AS requestType,
r.status AS status,
r.payload_json AS payloadJson,
r.original_json AS originalJson,
r.maker_id AS makerId,
maker.username AS makerUsername,
r.submitted_date AS submittedDate,
r.checker_id AS checkerId,
checker.username AS checkerUsername,
r.checked_date AS checkedDate,
r.reject_reason AS rejectReason
FROM m_user_pending_request r
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
""";
@Override
@Transactional(readOnly = true)
public Page<UserPendingRequestData> retrieveAllPendingRequests(final String status, final String username,
final String fromDate, final String toDate, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" WHERE 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" AND r.status = ?");
args.add(status.trim().toUpperCase());
} else {
where.append(" AND r.status = 'PENDING'");
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND r.submitted_date::date >= ?::date");
args.add(fromDate);
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND r.submitted_date::date <= ?::date");
args.add(toDate);
}
final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where;
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final int pageOffset = offset == null ? 0 : offset;
final int pageLimit = limit == null ? 20 : limit;
final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(pageLimit);
pageArgs.add(pageOffset);
final List<UserPendingRequestData> items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray());
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
@Override
@Transactional(readOnly = true)
public UserPendingRequestData retrievePendingRequest(final Long id) {
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
final String sql = SELECT_SQL + " WHERE r.id = ?";
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
}
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
if (payloadObj.has("username")) {
username = payloadObj.get("username").getAsString();
}
if (payloadObj.has("firstname")) {
firstname = payloadObj.get("firstname").getAsString();
}
if (payloadObj.has("lastname")) {
lastname = payloadObj.get("lastname").getAsString();
}
if (payloadObj.has("email")) {
email = payloadObj.get("email").getAsString();
}
if (payloadObj.has("mobileNo")) {
mobileNo = payloadObj.get("mobileNo").getAsString();
}
if (payloadObj.has("officeId")) {
officeId = payloadObj.get("officeId").getAsLong();
}
if (payloadObj.has("otpDeliveryMethod")) {
otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString();
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.build();
}
}
private static class UserPendingDetailRowMapper implements RowMapper<UserPendingRequestData> {
@Override
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long userId = JdbcSupport.getLong(rs, "userId");
final String requestType = rs.getString("requestType");
final String status = rs.getString("status");
final String payloadJson = rs.getString("payloadJson");
final String originalJson = rs.getString("originalJson");
final Long makerId = rs.getLong("makerId");
final String makerUsername = rs.getString("makerUsername");
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
final String checkerUsername = rs.getString("checkerUsername");
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final String rejectReason = rs.getString("rejectReason");
final Map<String, Object> proposedValues = new LinkedHashMap<>();
final Map<String, Object> originalValues = new LinkedHashMap<>();
String username = null;
String firstname = null;
String lastname = null;
String email = null;
String mobileNo = null;
Long officeId = null;
String otpDeliveryMethod = null;
Boolean passwordNeverExpires = null;
try {
if (payloadJson != null) {
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
for (final String key : payloadObj.keySet()) {
final JsonElement val = payloadObj.get(key);
if ("password".equals(key) || "repeatPassword".equals(key)) {
proposedValues.put("password", "Password will be set on approval");
} else if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
proposedValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
proposedValues.put(key, val.getAsNumber());
} else {
proposedValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
proposedValues.put(key, list);
}
}
if (proposedValues.containsKey("username")) {
username = (String) proposedValues.get("username");
}
if (proposedValues.containsKey("firstname")) {
firstname = (String) proposedValues.get("firstname");
}
if (proposedValues.containsKey("lastname")) {
lastname = (String) proposedValues.get("lastname");
}
if (proposedValues.containsKey("email")) {
email = (String) proposedValues.get("email");
}
if (proposedValues.containsKey("mobileNo")) {
mobileNo = (String) proposedValues.get("mobileNo");
}
if (proposedValues.get("officeId") instanceof Number n) {
officeId = n.longValue();
}
if (proposedValues.containsKey("otpDeliveryMethod")) {
otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod");
}
if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) {
passwordNeverExpires = b;
}
}
if (originalJson != null) {
final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject();
for (final String key : origObj.keySet()) {
final JsonElement val = origObj.get(key);
if (val.isJsonPrimitive()) {
if (val.getAsJsonPrimitive().isBoolean()) {
originalValues.put(key, val.getAsBoolean());
} else if (val.getAsJsonPrimitive().isNumber()) {
originalValues.put(key, val.getAsNumber());
} else {
originalValues.put(key, val.getAsString());
}
} else if (val.isJsonArray()) {
final List<String> list = new ArrayList<>();
for (final JsonElement elem : val.getAsJsonArray()) {
list.add(elem.getAsString());
}
originalValues.put(key, list);
}
}
if (proposedValues.containsKey("password")) {
originalValues.put("password", "[PROTECTED]");
}
}
} catch (Exception ignored) {
}
return UserPendingRequestData.builder()
.id(id)
.userId(userId)
.requestType(requestType)
.status(status)
.username(username)
.firstname(firstname)
.lastname(lastname)
.email(email)
.mobileNo(mobileNo)
.officeId(officeId)
.otpDeliveryMethod(otpDeliveryMethod)
.passwordNeverExpires(passwordNeverExpires)
.makerId(makerId)
.makerUsername(makerUsername)
.submittedDate(submittedDate)
.checkerId(checkerId)
.checkerUsername(checkerUsername)
.checkedDate(checkedDate)
.rejectReason(rejectReason)
.originalValues(originalValues)
.proposedValues(proposedValues)
.build();
}
}
}
@@ -0,0 +1,33 @@
/**
* 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.useradministration.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface UserPendingWritePlatformService {
CommandProcessingResult submitCreateUser(JsonCommand command);
CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command);
CommandProcessingResult approvePendingRequest(Long id);
CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command);
}
@@ -0,0 +1,386 @@
/**
* 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.useradministration.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
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.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.organisation.staff.domain.Staff;
import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.domain.Role;
import org.apache.fineract.useradministration.domain.RoleRepository;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.domain.UserPendingRequest;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepository;
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
import org.apache.fineract.useradministration.domain.UserPendingRequestType;
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
import org.apache.fineract.useradministration.exception.UserNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlatformService {
private final PlatformSecurityContext context;
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
private final UserPendingRequestRepository pendingRequestRepository;
private final AppUserRepository appUserRepository;
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final RoleRepository roleRepository;
private final StaffRepositoryWrapper staffRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final UserMfaFactorRepository userMfaFactorRepository;
private final ConfigurationDomainService configurationDomainService;
private final PasswordEncoder passwordEncoder;
private final UserDataValidator fromApiJsonDeserializer;
private final FromJsonHelper fromApiJsonHelper;
@Override
@Transactional
public CommandProcessingResult submitCreateUser(final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
this.fromApiJsonDeserializer.validateForCreate(command.json());
final String username = command.stringValueOfParameterNamed("username");
final String email = command.stringValueOfParameterNamed("email");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
// Pre-flight checks on live users and pending creations
if (this.appUserRepository.findAppUserByName(username) != null) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.username",
"User with username " + username + " already exists.", "username", username);
}
final String lockKey = "CREATE_USER:" + username.toLowerCase();
if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate",
"A pending creation request for username '" + username + "' is already awaiting checker approval.", username);
}
assertEmailAndMobileUnique(email, mobileNo, null);
final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject();
String passwordHash = null;
if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) {
final String rawPassword = jsonObject.get("password").getAsString();
if (!rawPassword.isBlank()) {
passwordHash = this.passwordEncoder.encode(rawPassword);
}
jsonObject.addProperty("password", "[PROTECTED]");
if (jsonObject.has("repeatPassword")) {
jsonObject.addProperty("repeatPassword", "[PROTECTED]");
}
}
final String sanitizedPayload = jsonObject.toString();
final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.CREATE_USER, null,
sanitizedPayload, null, passwordHash, maker.getId(), lockKey);
final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(saved.getId())
.setRollbackTransaction(true)
.build();
}
@Override
@Transactional
public CommandProcessingResult submitUpdateUser(final Long userId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final AppUser userToUpdate = this.appUserRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
this.fromApiJsonDeserializer.validateForUpdate(command.json(), maker);
final String email = command.stringValueOfParameterNamed("email");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
assertEmailAndMobileUnique(email, mobileNo, userId);
final String lockKey = "UPDATE_USER:" + userId;
if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate",
"A pending update request for user ID " + userId + " is already awaiting checker approval.", userId);
}
// Build point-in-time original JSON snapshot
final Map<String, Object> originalSnapshot = new LinkedHashMap<>();
originalSnapshot.put("username", userToUpdate.getUsername());
originalSnapshot.put("firstname", userToUpdate.getFirstname());
originalSnapshot.put("lastname", userToUpdate.getLastname());
originalSnapshot.put("email", userToUpdate.getEmail());
originalSnapshot.put("mobileNo", userToUpdate.getMobileNo());
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null);
originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires());
originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser());
final Set<String> roleNames = new HashSet<>();
final Set<Long> roleIds = new HashSet<>();
if (userToUpdate.getRoles() != null) {
for (final Role r : userToUpdate.getRoles()) {
roleNames.add(r.getName());
roleIds.add(r.getId());
}
}
originalSnapshot.put("roles", roleIds);
originalSnapshot.put("roleNames", roleNames);
final String originalJson = GoogleGsonSerializerHelper.createSimpleGson().toJson(originalSnapshot);
final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject();
String passwordHash = null;
if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) {
final String rawPassword = jsonObject.get("password").getAsString();
if (!rawPassword.isBlank()) {
passwordHash = this.passwordEncoder.encode(rawPassword);
}
jsonObject.addProperty("password", "[PROTECTED]");
if (jsonObject.has("repeatPassword")) {
jsonObject.addProperty("repeatPassword", "[PROTECTED]");
}
}
final String sanitizedPayload = jsonObject.toString();
final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.UPDATE_USER, userId,
sanitizedPayload, originalJson, passwordHash, maker.getId(), lockKey);
final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(saved.getId())
.setRollbackTransaction(true)
.build();
}
@Override
@Transactional
public CommandProcessingResult approvePendingRequest(final Long id) {
final AppUser checker = this.context.authenticatedUser();
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
if (!request.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending",
"User pending request " + id + " has already been actioned.", id);
}
if (request.isMadeBy(checker.getId())) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval",
"You cannot approve a user request you submitted yourself — a different user must action it.", id);
}
final Long resourceId;
if (UserPendingRequestType.CREATE_USER.name().equals(request.getRequestType())) {
resourceId = finalizeCreateUser(request);
} else if (UserPendingRequestType.UPDATE_USER.name().equals(request.getRequestType())) {
resourceId = finalizeUpdateUser(request);
} else {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.type.invalid",
"Unknown request type: " + request.getRequestType(), request.getRequestType());
}
request.approve(checker.getId());
this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(resourceId)
.withResourceIdAsString(resourceId.toString())
.build();
}
@Override
@Transactional
public CommandProcessingResult rejectPendingRequest(final Long id, final JsonCommand command) {
final AppUser checker = this.context.authenticatedUser();
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
if (!request.isPending()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending",
"User pending request " + id + " has already been actioned.", id);
}
if (request.isMadeBy(checker.getId())) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval",
"You cannot reject a user request you submitted yourself — a different user must action it.", id);
}
String reason = command != null ? command.stringValueOfParameterNamedAllowingNull("reason") : null;
if (reason == null && command != null) {
reason = command.stringValueOfParameterNamedAllowingNull("rejectReason");
}
if (reason == null || reason.isBlank()) {
throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.reject.reason.required",
"A reason is required to reject a user pending request.", id);
}
request.reject(checker.getId(), reason);
this.pendingRequestRepositoryWrapper.saveAndFlush(request);
return new CommandProcessingResultBuilder()
.withEntityId(request.getId())
.withResourceIdAsString(request.getId().toString())
.build();
}
private Long finalizeCreateUser(final UserPendingRequest request) {
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
final JsonCommand replay = JsonCommand.fromJsonElement(null, parsedElement, this.fromApiJsonHelper);
final Long officeId = replay.longValueOfParameterNamed("officeId");
final Office userOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
final String[] roles = replay.arrayValueOfParameterNamed("roles");
final Set<Role> allRoles = assembleSetOfRoles(roles);
final Long staffId = replay.longValueOfParameterNamed("staffId");
final Staff linkedStaff = staffId != null
? this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userOffice.getId())
: null;
final Collection<Client> clients = null;
final AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, replay);
if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
appUser.updatePasswordResetRequired(true);
}
// Set pre-hashed password
if (request.getPasswordHash() != null) {
appUser.updatePassword(request.getPasswordHash());
}
this.appUserRepository.saveAndFlush(appUser);
// MFA Enrollment
final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod");
if (otpDeliveryMethod != null) {
final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod);
final UserMfaFactor factor = new UserMfaFactor();
factor.setAppUser(appUser);
factor.setFactorType(factorType);
factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail());
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
this.userMfaFactorRepository.save(factor);
}
return appUser.getId();
}
private Long finalizeUpdateUser(final UserPendingRequest request) {
final AppUser userToUpdate = this.appUserRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
if (changes.containsKey("officeId")) {
final Long officeId = (Long) changes.get("officeId");
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
userToUpdate.changeOffice(office);
}
if (changes.containsKey("staffId")) {
final Long staffId = (Long) changes.get("staffId");
Staff linkedStaff = null;
if (staffId != null) {
linkedStaff = this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userToUpdate.getOffice().getId());
}
userToUpdate.changeStaff(linkedStaff);
}
if (changes.containsKey("roles")) {
final String[] roleIds = (String[]) changes.get("roles");
final Set<Role> allRoles = assembleSetOfRoles(roleIds);
userToUpdate.updateRoles(allRoles);
}
if (request.getPasswordHash() != null) {
userToUpdate.updatePassword(request.getPasswordHash());
}
this.appUserRepository.saveAndFlush(userToUpdate);
return userToUpdate.getId();
}
private Set<Role> assembleSetOfRoles(final String[] rolesArray) {
final Set<Role> allRoles = new HashSet<>();
if (!ObjectUtils.isEmpty(rolesArray)) {
for (final String roleId : rolesArray) {
final Long id = Long.valueOf(roleId);
final Role role = this.roleRepository.findById(id).orElseThrow(() -> new RoleNotFoundException(id));
allRoles.add(role);
}
}
return allRoles;
}
private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) {
if (email != null && !email.isBlank()) {
final java.util.Optional<AppUser> emailOwner = excludeUserId == null
? this.appUserRepository.findByEmailIgnoreCase(email)
: this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId);
if (emailOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
}
if (mobileNo != null && !mobileNo.isBlank()) {
final java.util.Optional<AppUser> mobileOwner = excludeUserId == null
? this.appUserRepository.findByMobileNo(mobileNo)
: this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId);
if (mobileOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo);
}
}
}
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -688,6 +688,13 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# │ DIP/MNRL provider. Do not ship the mock. │ # │ DIP/MNRL provider. Do not ship the mock. │
# └──────────────────────────────────────────────────────────────────────────┘ # └──────────────────────────────────────────────────────────────────────────┘
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090} fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
# Set to false to skip the DIP/MNRL check entirely (no HTTP call at all, no need to run the mock
# service) -- every number is then logged as CLEAN but stamped "LOOKUP_DISABLED_BY_CONFIG" so it is
# never mistaken for a real pass.
# Currently OFF by default for local work so nobody needs the mock service running. Set
# FINERACT_MOBILE_LOOKUP_ENABLED=true (or flip the default below) before UAT/production, where the
# check must actually run.
fineract.mobile-lookup.enabled=${FINERACT_MOBILE_LOOKUP_ENABLED:false}
# ── Login OTP (2FA) — uses LincPay credentials above ───────────────────────── # ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp} fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
@@ -537,4 +537,12 @@
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/> <include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/> <include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/> <include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0466_create_m_appuser_preference.xml" relativeToChangelogFile="true"/>
<include file="parts/0467_enable_glaccount_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_enable_cbs_base_interest_rate_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_enable_charge_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_enable_product_creation_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_enable_fixed_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_enable_admin_user_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_user_pending_request_and_enable_charge_mc.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet id="0466_create_m_appuser_preference" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_appuser_preference"/></not></preConditions>
<createTable tableName="m_appuser_preference">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="value" type="TEXT">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_appuser_preference" baseColumnNames="appuser_id" constraintName="fk_m_appuser_preference_appuser_id" referencedTableName="m_appuser" referencedColumnNames="id"/>
<addUniqueConstraint tableName="m_appuser_preference" columnNames="appuser_id, name" constraintName="uq_appuser_preference_name"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0467_enable_glaccount_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="3">
SELECT COUNT(*)
FROM m_permission
WHERE entity_name = 'GLACCOUNT'
AND action_name IN ('CREATE', 'UPDATE', 'DELETE')
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'GLACCOUNT'
AND action_name IN ('CREATE', 'UPDATE', 'DELETE');
</sql>
</changeSet>
</databaseChangeLog>
@@ -7,6 +7,7 @@
address as verified or not verified once the outcome is known. Deliberately a plain column rather than address as verified or not verified once the outcome is known. Deliberately a plain column rather than
a foreign key: the dispatch is a historical record and must survive the address being edited or removed. --> a foreign key: the dispatch is a historical record and must survive the address being edited or removed. -->
<changeSet id="0468_add_address_id_to_welcome_kit_dispatch" author="CBS" runOnChange="false"> <changeSet id="0468_add_address_id_to_welcome_kit_dispatch" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN"> <preConditions onFail="MARK_RAN">
<not> <not>
<columnExists tableName="m_welcome_kit_dispatch" columnName="address_id"/> <columnExists tableName="m_welcome_kit_dispatch" columnName="address_id"/>
@@ -7,6 +7,7 @@
person's authority. The requester states a reason, an admin approves or rejects, and only an approved person's authority. The requester states a reason, an admin approves or rejects, and only an approved
request can produce a dump. Every step is stamped so the trail survives the file itself. --> request can produce a dump. Every step is stamped so the trail survives the file itself. -->
<changeSet id="0469_create_database_export_request" author="CBS" runOnChange="false"> <changeSet id="0469_create_database_export_request" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<createTable tableName="m_database_export_request"> <createTable tableName="m_database_export_request">
<column name="id" type="BIGSERIAL"> <column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/> <constraints primaryKey="true" nullable="false"/>
@@ -50,6 +51,7 @@
<!-- Requesting an export and approving one are deliberately separate permissions: the whole control <!-- Requesting an export and approving one are deliberately separate permissions: the whole control
depends on no single user holding both. --> depends on no single user holding both. -->
<changeSet id="0469_database_export_permissions" author="CBS" runOnChange="false"> <changeSet id="0469_database_export_permissions" author="CBS" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql> <sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'authorisation', 'CREATE_DATABASEEXPORT', 'DATABASEEXPORT', 'CREATE', false SELECT 'authorisation', 'CREATE_DATABASEEXPORT', 'DATABASEEXPORT', 'CREATE', false
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0470_cbs_base_interest_rate_maker_checker_permissions" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE', 'CBSBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE_CHECKER', 'CBSBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE_CHECKER');
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CBSBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_CHECKER'
WHERE entity_name = 'CBSBASEINTERESTRATE'
AND code = 'CREATE_CBSBASEINTERESTRATE_CHECKER';
</sql>
</changeSet>
<changeSet id="0470_grant_cbs_base_interest_rate_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0471_enable_charge_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="1">
SELECT COUNT(*)
FROM m_permission
WHERE entity_name = 'CHARGE'
AND action_name = 'CREATE'
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CHARGE'
AND action_name = 'CREATE';
</sql>
</changeSet>
<changeSet id="0471_grant_charge_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0472_add_shareproduct_checker_permission_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER', 'SHAREPRODUCT', 'CREATE_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'CREATE_SHAREPRODUCT_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0472_enable_product_creation_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="4">
SELECT COUNT(*)
FROM m_permission
WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT')
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT');
</sql>
</changeSet>
<changeSet id="0472_grant_product_creation_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN (
'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER',
'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER',
'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER',
'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0474_add_fixeddepositproduct_checker_permission_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER', 'FIXEDDEPOSITPRODUCT', 'CREATE_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0474_enable_fixed_deposit_product_creation_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="1">
SELECT COUNT(*)
FROM m_permission
WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT'
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT';
</sql>
</changeSet>
<changeSet id="0474_grant_fixed_deposit_product_creation_maker_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0475_add_permissions_role_checker_if_not_exists" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'authorisation', 'PERMISSIONS_ROLE_CHECKER', 'ROLE', 'PERMISSIONS_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'PERMISSIONS_ROLE_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0475_enable_admin_user_and_role_maker_checker" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="HALT">
<sqlCheck expectedResult="9">
SELECT COUNT(*)
FROM m_permission
WHERE code IN (
'CREATE_USER', 'UPDATE_USER', 'DELETE_USER',
'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE',
'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE'
)
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN (
'CREATE_USER', 'UPDATE_USER', 'DELETE_USER',
'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE',
'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE'
);
</sql>
</changeSet>
<changeSet id="0475_grant_admin_user_and_role_checker_permissions_to_roles" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER',
'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER',
'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER',
'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER',
'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0476_create_m_user_pending_request_table" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
CREATE TABLE IF NOT EXISTS m_user_pending_request (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NULL REFERENCES m_appuser(id) ON DELETE SET NULL,
request_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
payload_json TEXT NOT NULL,
original_json TEXT NULL,
password_hash VARCHAR(255) NULL,
maker_id BIGINT NOT NULL REFERENCES m_appuser(id),
submitted_date TIMESTAMPTZ NOT NULL,
checker_id BIGINT NULL REFERENCES m_appuser(id),
checked_date TIMESTAMPTZ NULL,
reject_reason VARCHAR(500) NULL,
pending_lock_key VARCHAR(100) NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_status ON m_user_pending_request(status);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_user_id ON m_user_pending_request(user_id);
CREATE INDEX IF NOT EXISTS idx_user_pending_req_maker ON m_user_pending_request(maker_id);
</sql>
</changeSet>
<changeSet id="0476_configure_user_and_charge_maker_checker_permissions" author="fineract" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Disable native MC on CREATE_USER and UPDATE_USER to avoid core password sanitization error
UPDATE m_permission
SET can_maker_checker = FALSE
WHERE code IN ('CREATE_USER', 'UPDATE_USER');
-- Enable native MC on UPDATE_CHARGE and DELETE_CHARGE
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('UPDATE_CHARGE', 'DELETE_CHARGE');
-- Insert bespoke user maker-checker permissions
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'SUBMIT_CREATE_USER', 'USER', 'SUBMIT_CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_CREATE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'SUBMIT_UPDATE_USER', 'USER', 'SUBMIT_UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_UPDATE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'APPROVE_USER', 'USER', 'APPROVE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'APPROVE_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'REJECT_USER', 'USER', 'REJECT', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REJECT_USER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'user_administration', 'READ_USER_PENDING_REQUEST', 'USER', 'READ_PENDING', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USER_PENDING_REQUEST');
-- Grant permissions to Super user
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN (
'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER',
'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
-- Grant permissions to bankadmin
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN (
'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER',
'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST'
)
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -28,6 +28,7 @@ import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSource;
import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.codes.exception.CodeNotFoundException; import org.apache.fineract.infrastructure.codes.exception.CodeNotFoundException;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -113,4 +114,528 @@ public class CommandSourceServiceTest {
Assertions.assertEquals(1001, result.getErrorCode()); Assertions.assertEquals(1001, result.getErrorCode());
Assertions.assertTrue(result.getMessage().contains("Code with name `foo` does not exist")); Assertions.assertTrue(result.getMessage().contains("Code with name `foo` does not exist"));
} }
@Test
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, superUser, false);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(superUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Create() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(100L);
Mockito.when(commandSource.getResourceId()).thenReturn(200L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Update_And_Disable() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("UPDATE");
Mockito.when(commandSource.getId()).thenReturn(101L);
Mockito.when(commandSource.getResourceId()).thenReturn(201L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Delete() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("DELETE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("DELETE");
Mockito.when(commandSource.getId()).thenReturn(102L);
Mockito.when(commandSource.getResourceId()).thenReturn(202L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("DELETE_GLACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_GLAccount_CheckerApproval_ExecutesSuccessfully() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("UPDATE");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true);
Mockito.when(checkerUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_GLAccount_NonSuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser regularUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(103L);
Mockito.when(commandSource.getResourceId()).thenReturn(203L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true);
Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CBSBaseInterestRate_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE");
Mockito.when(commandSource.getEntityName()).thenReturn("CBSBASEINTERESTRATE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(104L);
Mockito.when(commandSource.getResourceId()).thenReturn(204L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CBSBaseInterestRate_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_Charge_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE");
Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(105L);
Mockito.when(commandSource.getResourceId()).thenReturn(205L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_Charge_NonSuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser regularUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE");
Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(106L);
Mockito.when(commandSource.getResourceId()).thenReturn(206L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true);
Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_LoanProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("LOANPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(110L);
Mockito.when(commandSource.getResourceId()).thenReturn(210L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_SavingsProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SAVINGSPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(111L);
Mockito.when(commandSource.getResourceId()).thenReturn(211L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SAVINGSPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_CurrentProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CURRENTPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("CURRENTPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(112L);
Mockito.when(commandSource.getResourceId()).thenReturn(212L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CURRENTPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_ShareProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SHAREPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("SHAREPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(113L);
Mockito.when(commandSource.getResourceId()).thenReturn(213L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SHAREPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_FixedDepositProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("FIXEDDEPOSITPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(114L);
Mockito.when(commandSource.getResourceId()).thenReturn(214L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_FixedDepositProduct_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_Products_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
public void testProcessCommand_UserOperations_SuperUserBypassBlocked_ThrowsRollbackException() {
String[] actions = { "CREATE", "UPDATE", "DELETE" };
String[] permissions = { "CREATE_USER", "UPDATE_USER", "DELETE_USER" };
for (int i = 0; i < actions.length; i++) {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getEntityName()).thenReturn("USER");
Mockito.when(commandSource.getActionName()).thenReturn(actions[i]);
Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]);
Mockito.when(commandSource.isSanitized()).thenReturn(false);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
}
@Test
public void testProcessCommand_RoleOperations_SuperUserBypassBlocked_ThrowsRollbackException() {
String[] actions = { "CREATE", "UPDATE", "PERMISSIONS", "DELETE" };
String[] permissions = { "CREATE_ROLE", "UPDATE_ROLE", "PERMISSIONS_ROLE", "DELETE_ROLE" };
for (int i = 0; i < actions.length; i++) {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getEntityName()).thenReturn("ROLE");
Mockito.when(commandSource.getActionName()).thenReturn(actions[i]);
Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]);
Mockito.when(commandSource.isSanitized()).thenReturn(false);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
}
@Test
public void testProcessCommand_UserAndRole_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser checkerUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_USER");
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_USER")).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, checkerUser, true);
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(checkerUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
} }
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.charge.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.charge.service.ChargeWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateChargeDefinitionCommandHandlerTest {
@Mock
private ChargeWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateChargeDefinitionCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(101L)
.withEntityId(201L)
.build();
when(writePlatformService.createCharge(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createCharge(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanproduct.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateLoanProductCommandHandlerTest {
@Mock
private LoanProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateLoanProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(101L)
.withEntityId(201L)
.build();
when(writePlatformService.createLoanProduct(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createLoanProduct(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateCbsBaseInterestRateCommandHandlerTest {
@Mock
private FdBaseInterestRateWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateCbsBaseInterestRateCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(100L)
.withEntityId(200L)
.build();
when(writePlatformService.saveInterestRates(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).saveInterestRates(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.savings.service.SavingsProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateSavingsProductCommandHandlerTest {
@Mock
private SavingsProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateSavingsProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(102L)
.withEntityId(202L)
.build();
when(writePlatformService.create(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).create(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler.current;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateCurrentProductCommandHandlerTest {
@Mock
private CurrentProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateCurrentProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(103L)
.withEntityId(203L)
.build();
when(writePlatformService.create(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).create(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -0,0 +1,265 @@
/**
* 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.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffective;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveRepository;
import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository;
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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FdBaseInterestRateReadPlatformServiceImplTest {
@Mock
private FdBaseInterestRateEffectiveRepository effectiveRepository;
@Mock
private FdBaseInterestRateSlabHistoryRepository slabRepository;
@InjectMocks
private FdBaseInterestRateReadPlatformServiceImpl service;
@BeforeEach
void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
}
@AfterEach
void tearDown() {
ThreadLocalContextUtil.setTenant(null);
}
private FdInterestRateSaveRequest.SlabRequest createSlab(int srNo, String desc, int periodType, int from, Integer to, double rate, double seniorRate) {
FdInterestRateSaveRequest.SlabRequest s = new FdInterestRateSaveRequest.SlabRequest();
s.setSrNo(srNo);
s.setDescription(desc);
s.setPeriodTypeEnum(periodType);
s.setFromPeriod(from);
s.setToPeriod(to);
s.setAnnualInterestRate(BigDecimal.valueOf(rate));
s.setSeniorCitizenRate(BigDecimal.valueOf(seniorRate));
return s;
}
@Test
void testValidSingleDepositSlabs() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
request.setVersionLabel("v1.0");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "30 to 89 Days", 0, 30, 89, 5.0, 5.5),
createSlab(3, "90 to 179 Days", 0, 90, 179, 5.5, 6.0),
createSlab(4, "180+ Days", 0, 180, null, 6.0, 6.5)
);
request.setSlabs(slabs);
when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("SINGLE"))
.thenReturn(Collections.emptyList());
when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0));
assertDoesNotThrow(() -> service.saveInterestRates(request, 1L));
verify(effectiveRepository).saveAndFlush(any());
}
@Test
void testValidDoubleDepositSlabs() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("DOUBLE");
request.setVersionLabel("v1.0");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 2 Years", 3, 1, 2, 6.5, 7.0),
createSlab(2, "3 to 5 Years", 3, 3, 5, 6.75, 7.25),
createSlab(3, "6 to 10 Years", 3, 6, 10, 7.0, 7.5),
createSlab(4, "11+ Years", 3, 11, null, 7.25, 7.75)
);
request.setSlabs(slabs);
when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("DOUBLE"))
.thenReturn(Collections.emptyList());
when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0));
assertDoesNotThrow(() -> service.saveInterestRates(request, 1L));
}
@Test
void testInvalidFirstSlabDoesNotStartAtOne() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "2 to 29 Days", 0, 2, 29, 4.5, 5.0),
createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals(1, ex.getErrors().size());
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].fromPeriod.first.slab.must.start.at.one",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidOverlapRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "20 to 50 Days", 0, 20, 50, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidGapBetweenRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "35 to 89 Days", 0, 35, 89, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.be.continuous.with.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidDuplicateRanges() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "1 to 29 Days duplicate", 0, 1, 29, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidFromGreaterThanTo() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 20 Days", 0, 1, 20, 4.5, 5.0),
createSlab(2, "21 to 10 Days", 0, 21, 10, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].toPeriod.cannot.be.less.than.fromPeriod",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidBlankToPeriodOnIntermediateSlab() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to null Days", 0, 1, null, 4.5, 5.0),
createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].toPeriod.cannot.be.blank.for.intermediate.slab",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidMixedPeriodTypes() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0),
createSlab(2, "30 to 89 Months", 2, 30, 89, 5.0, 5.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].periodTypeEnum.must.be.consistent.across.all.slabs",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidNegativeInterestRate() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, -1.0, 5.0)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].annualInterestRate.must.be.non.negative",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
@Test
void testInvalidNegativeSeniorRate() {
FdInterestRateSaveRequest request = new FdInterestRateSaveRequest();
request.setFdType("SINGLE");
List<FdInterestRateSaveRequest.SlabRequest> slabs = List.of(
createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, -0.5)
);
request.setSlabs(slabs);
PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class,
() -> service.saveInterestRates(request, 1L));
assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].seniorCitizenRate.must.be.non.negative",
ex.getErrors().get(0).getUserMessageGlobalisationCode());
}
}
@@ -0,0 +1,80 @@
/**
* 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.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FdBaseInterestRateWritePlatformServiceImplTest {
@Mock
private PlatformSecurityContext context;
@Mock
private FdBaseInterestRateReadPlatformService readPlatformService;
@Mock
private JsonCommand command;
@Mock
private AppUser appUser;
@InjectMocks
private FdBaseInterestRateWritePlatformServiceImpl writePlatformService;
@Test
void shouldParseJsonAndSaveInterestRates() {
String jsonPayload = "{\"fdType\":\"SINGLE\",\"versionLabel\":\"v1.0\",\"slabs\":[{\"srNo\":1,\"description\":\"1 to 29 Days\",\"periodTypeEnum\":0,\"fromPeriod\":1,\"toPeriod\":29,\"annualInterestRate\":5.0,\"seniorCitizenRate\":5.5}]}";
when(command.json()).thenReturn(jsonPayload);
when(command.commandId()).thenReturn(789L);
when(context.authenticatedUser()).thenReturn(appUser);
when(appUser.getId()).thenReturn(1L);
FdInterestRateEffectiveData savedData = FdInterestRateEffectiveData.builder()
.id(10L)
.fdType("SINGLE")
.versionLabel("v1.0")
.build();
when(readPlatformService.saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L))).thenReturn(savedData);
CommandProcessingResult result = writePlatformService.saveInterestRates(command);
assertNotNull(result);
assertEquals(789L, result.getCommandId());
assertEquals(10L, result.getResourceId());
verify(readPlatformService).saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L));
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.shareproducts.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductWritePlatformService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CreateShareProductCommandHandlerTest {
@Mock
private ShareProductWritePlatformService writePlatformService;
@Mock
private JsonCommand command;
@InjectMocks
private CreateShareProductCommandHandler underTest;
@Test
void shouldDelegateToWritePlatformServiceAndReturnResult() {
CommandProcessingResult expectedResult = new CommandProcessingResultBuilder()
.withCommandId(104L)
.withEntityId(204L)
.build();
when(writePlatformService.createShareProduct(command)).thenReturn(expectedResult);
CommandProcessingResult actualResult = underTest.processCommand(command);
verify(writePlatformService).createShareProduct(command);
assertEquals(expectedResult, actualResult);
}
}
@@ -3008,7 +3008,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
} }
public Money activateWithBalance() { public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance); // Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
} }
public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) {
@@ -3700,10 +3703,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
} }
private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { private Money minRequiredBalanceDerived(final MonetaryCurrency currency) {
// Minimum required balance is not enforced -- withdrawals (and every other balance check
// that goes through this) must never be blocked by a configured minimum balance. Overdraft
// headroom is a separate concept and still applies.
Money minReqBalance = Money.zero(currency); Money minReqBalance = Money.zero(currency);
if (this.enforceMinRequiredBalance) {
minReqBalance = minReqBalance.plus(this.minRequiredBalance);
}
if (this.allowOverdraft) { if (this.allowOverdraft) {
minReqBalance = minReqBalance.minus(this.overdraftLimit); minReqBalance = minReqBalance.minus(this.overdraftLimit);
} }
@@ -160,7 +160,11 @@ public class FacilityChargeAutoApplyService {
} }
} }
if (!transactions.isEmpty()) { if (!transactions.isEmpty()) {
savingsAccountTransactionRepository.saveAll(transactions); // Flush immediately (not just save) so each transaction gets its DB-generated id right
// away -- the caller posts GL journal entries for these transactions straight after this
// call returns, and that requires a real id on each one, not one that only appears at
// end-of-transaction commit.
savingsAccountTransactionRepository.saveAllAndFlush(transactions);
account.recalculateBalancesAfterFacilityCharges(transactionDate); account.recalculateBalancesAfterFacilityCharges(transactionDate);
} }
return transactions; return transactions;
@@ -79,6 +79,9 @@ public interface SavingsAccountWritePlatformService {
void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds, void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds); Set<Long> existingReversedTransactionIds);
void postJournalEntriesForSavingsAccount(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds, boolean backdatedTxnsAllowedTill);
CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command); CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command);
void setSubStatusInactive(Long savingsId); void setSubStatusInactive(Long savingsId);