This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 27bf6d05d572ce5c5e86c065a6d5609af15d27d2 Author: Attila Budai <[email protected]> AuthorDate: Mon Jul 7 15:18:37 2025 +0200 FINERACT-2181: Moneyhelper multitenant configuration fix, rework it to be a utility class --- .../organisation/monetary/domain/MoneyHelper.java | 174 +++++++++-- .../domain/MoneyHelperTenantIsolationTest.java | 347 +++++++++++++++++++++ .../api/InternalConfigurationsApiResource.java | 14 +- .../service/MoneyHelperInitializationService.java | 115 +++++++ .../MoneyHelperStartupInitializationService.java | 96 ++++++ .../domain/DefaultScheduledDateGeneratorTest.java | 12 +- ...nWritePlatformServiceJpaRepositoryImplTest.java | 15 +- 7 files changed, 726 insertions(+), 47 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java index 8300a463b5..f605f8e8de 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java @@ -18,54 +18,176 @@ */ package org.apache.fineract.organisation.monetary.domain; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import jakarta.annotation.PostConstruct; import java.math.MathContext; import java.math.RoundingMode; +import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +/** + * Pure utility class for monetary calculations and rounding operations. This class does not depend on Spring components + * or configuration services. All rounding modes are initialized at startup and cached per tenant. + */ @Slf4j -@Component -public class MoneyHelper { +public final class MoneyHelper { - private static RoundingMode roundingMode = null; - private static MathContext mathContext; public static final int PRECISION = 19; + private static final RoundingMode DEFAULT_ROUNDING_MODE = RoundingMode.HALF_EVEN; - private static ConfigurationDomainService staticConfigurationDomainService; + private static final ConcurrentHashMap<String, RoundingMode> roundingModeCache = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap<String, MathContext> mathContextCache = new ConcurrentHashMap<>(); - @Autowired - private ConfigurationDomainService configurationDomainService; + // Private constructor to prevent instantiation + private MoneyHelper() { + throw new UnsupportedOperationException("MoneyHelper is a utility class and cannot be instantiated"); + } - // This is a hack, but fixing this is not trivial, because some @Entity - // domain classes use this helper - @PostConstruct - @SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") - public void initialize() { - staticConfigurationDomainService = configurationDomainService; + /** + * Initialize rounding mode for a specific tenant. This method should be called during application startup for each + * tenant. + * + * @param tenantIdentifier + * the tenant identifier + * @param roundingModeValue + * the rounding mode value (0-6) + */ + public static void initializeTenantRoundingMode(String tenantIdentifier, int roundingModeValue) { + if (tenantIdentifier == null) { + throw new IllegalArgumentException("Tenant identifier cannot be null"); + } + + RoundingMode roundingMode = validateAndConvertRoundingMode(roundingModeValue); + roundingModeCache.put(tenantIdentifier, roundingMode); + // Clear math context cache to force recreation with new rounding mode + mathContextCache.remove(tenantIdentifier); + + log.info("Initialized rounding mode for tenant {}: {}", tenantIdentifier, roundingMode.name()); } + /** + * Get the rounding mode for the current tenant context. + * + * @return the tenant-specific rounding mode + * @throws IllegalStateException + * if no tenant context is available or tenant is not initialized + */ public static RoundingMode getRoundingMode() { + String tenantId = getTenantIdentifier(); + RoundingMode roundingMode = roundingModeCache.get(tenantId); + if (roundingMode == null) { - roundingMode = RoundingMode.valueOf(staticConfigurationDomainService.getRoundingMode()); + // For integration tests and development, use default if not initialized + log.warn("Rounding mode not initialized for tenant: {}. Using default: {}", tenantId, DEFAULT_ROUNDING_MODE); + return DEFAULT_ROUNDING_MODE; } return roundingMode; } + /** + * Get the math context for the current tenant context. + * + * @return the tenant-specific math context with precision and rounding mode + * @throws IllegalStateException + * if no tenant context is available or tenant is not initialized + */ public static MathContext getMathContext() { - if (mathContext == null) { - mathContext = new MathContext(PRECISION, getRoundingMode()); + String tenantId = getTenantIdentifier(); + return mathContextCache.computeIfAbsent(tenantId, k -> new MathContext(PRECISION, getRoundingMode())); + } + + /** + * Update the rounding mode for a specific tenant. This method should be called when tenant configuration changes. + * + * @param tenantIdentifier + * the tenant identifier + * @param roundingModeValue + * the new rounding mode value (0-6) + */ + public static void updateTenantRoundingMode(String tenantIdentifier, int roundingModeValue) { + if (tenantIdentifier == null) { + throw new IllegalArgumentException("Tenant identifier cannot be null"); } - return mathContext; + + RoundingMode roundingMode = validateAndConvertRoundingMode(roundingModeValue); + roundingModeCache.put(tenantIdentifier, roundingMode); + mathContextCache.remove(tenantIdentifier); // Force recreation with new rounding mode + + log.info("Updated rounding mode for tenant {}: {}", tenantIdentifier, roundingMode.name()); } - public static void fetchRoundingModeFromGlobalConfig() { - roundingMode = RoundingMode.valueOf(staticConfigurationDomainService.getRoundingMode()); - log.info("Fetch Rounding Mode from Global Config {}", roundingMode.name()); - mathContext = null; + /** + * Create a MathContext with custom rounding mode. This utility method doesn't require tenant context. + * + * @param roundingMode + * the rounding mode to use + * @return a MathContext with the specified rounding mode + */ + public static MathContext createMathContext(RoundingMode roundingMode) { + return new MathContext(PRECISION, roundingMode); } + /** + * Clear all cached data for all tenants. This method should be used carefully, typically during application + * shutdown or full reset. + */ + public static void clearCache() { + roundingModeCache.clear(); + mathContextCache.clear(); + log.info("MoneyHelper cache cleared for all tenants"); + } + + /** + * Clear cached data for a specific tenant. This method should be called when a tenant is removed or reset. + * + * @param tenantId + * the tenant identifier + */ + public static void clearCacheForTenant(String tenantId) { + if (tenantId == null) { + return; + } + + roundingModeCache.remove(tenantId); + mathContextCache.remove(tenantId); + log.info("MoneyHelper cache cleared for tenant: {}", tenantId); + } + + /** + * Get all initialized tenants. This method is useful for monitoring and debugging. + * + * @return set of tenant identifiers that have been initialized + */ + public static java.util.Set<String> getInitializedTenants() { + return java.util.Collections.unmodifiableSet(roundingModeCache.keySet()); + } + + /** + * Check if a tenant is initialized. + * + * @param tenantIdentifier + * the tenant identifier + * @return true if the tenant is initialized, false otherwise + */ + public static boolean isTenantInitialized(String tenantIdentifier) { + return tenantIdentifier != null && roundingModeCache.containsKey(tenantIdentifier); + } + + private static String getTenantIdentifier() { + FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant(); + if (tenant != null) { + return tenant.getTenantIdentifier(); + } + throw new IllegalStateException( + "No tenant context available. " + "MoneyHelper requires a valid tenant context to ensure proper multi-tenant isolation."); + } + + private static RoundingMode validateAndConvertRoundingMode(int roundingModeValue) { + if (roundingModeValue < 0 || roundingModeValue > 6) { + throw new IllegalArgumentException("Invalid rounding mode value: " + roundingModeValue + + ". Valid values are 0-6 (corresponding to RoundingMode enum ordinals)"); + } + + return RoundingMode.valueOf(roundingModeValue); + } } diff --git a/fineract-core/src/test/java/org/apache/fineract/organisation/monetary/domain/MoneyHelperTenantIsolationTest.java b/fineract-core/src/test/java/org/apache/fineract/organisation/monetary/domain/MoneyHelperTenantIsolationTest.java new file mode 100644 index 0000000000..6b72ee0312 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/organisation/monetary/domain/MoneyHelperTenantIsolationTest.java @@ -0,0 +1,347 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.monetary.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.Set; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Test to verify that the MoneyHelper tenant isolation fix works correctly with pure utility class approach. This + * validates that PS-2617 has been properly resolved using the new architecture. + */ +class MoneyHelperTenantIsolationTest { + + private FineractPlatformTenant tenantA; + private FineractPlatformTenant tenantB; + private FineractPlatformTenant originalTenant; + + @BeforeEach + void setUp() { + // Store original tenant to restore later + originalTenant = ThreadLocalContextUtil.getTenant(); + + // Create test tenants + tenantA = new FineractPlatformTenant(1L, "tenantA", "Tenant A", "Asia/Kolkata", null); + tenantB = new FineractPlatformTenant(2L, "tenantB", "Tenant B", "Asia/Kolkata", null); + + // Clear cache to ensure clean test state + MoneyHelper.clearCache(); + } + + @AfterEach + void tearDown() { + // Restore original tenant context + ThreadLocalContextUtil.setTenant(originalTenant); + + // Clear cache to prevent test interference + MoneyHelper.clearCache(); + } + + @Test + @DisplayName("FIXED: MoneyHelper now provides proper tenant isolation using pure utility class") + void testProperTenantIsolationWithPureUtilityClass() { + // Initialize tenants with different rounding modes + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantB", 6); // HALF_EVEN + + // Step 1: Tenant A requests rounding mode + ThreadLocalContextUtil.setTenant(tenantA); + RoundingMode tenantARoundingMode = MoneyHelper.getRoundingMode(); + + // Step 2: Tenant B requests rounding mode (should get their own config) + ThreadLocalContextUtil.setTenant(tenantB); + RoundingMode tenantBRoundingMode = MoneyHelper.getRoundingMode(); + + // VERIFY: Each tenant gets their configured rounding mode + assertEquals(RoundingMode.HALF_UP, tenantARoundingMode, "Tenant A should get HALF_UP"); + assertEquals(RoundingMode.HALF_EVEN, tenantBRoundingMode, "Tenant B should get HALF_EVEN"); + + // VERIFY: Tenants have different rounding modes (isolation confirmed) + assertNotEquals(tenantARoundingMode, tenantBRoundingMode, "FIXED: Tenants now have proper isolation with different rounding modes"); + } + + @Test + @DisplayName("FIXED: MathContext isolation provides tenant-specific financial calculations") + void testProperMathContextIsolation() { + // Initialize tenants with different rounding modes + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantB", 6); // HALF_EVEN + + // Create test amount that will show rounding differences when divided by 3 + MonetaryCurrency currency = new MonetaryCurrency("USD", 2, null); + BigDecimal testAmount = new BigDecimal("1.00"); // 1.00 / 3 = 0.333... which rounds differently + + // Tenant A gets MathContext and creates Money with it + ThreadLocalContextUtil.setTenant(tenantA); + MathContext tenantAMathContext = MoneyHelper.getMathContext(); + Money tenantAMoney = Money.of(currency, testAmount, tenantAMathContext); + Money tenantAResult = tenantAMoney.dividedBy(BigDecimal.valueOf(3), tenantAMathContext); // 1.00 / 3 = 0.333... + + // Tenant B gets MathContext (should be different now) and creates Money with it + ThreadLocalContextUtil.setTenant(tenantB); + MathContext tenantBMathContext = MoneyHelper.getMathContext(); + Money tenantBMoney = Money.of(currency, testAmount, tenantBMathContext); + Money tenantBResult = tenantBMoney.dividedBy(BigDecimal.valueOf(3), tenantBMathContext); // 1.00 / 3 = 0.333... + + // VERIFY: Different MathContext objects with different rounding modes + assertNotEquals(tenantAMathContext, tenantBMathContext, "FIXED: Tenants now get different MathContext instances"); + assertNotEquals(tenantAMathContext.getRoundingMode(), tenantBMathContext.getRoundingMode(), + "FIXED: Tenants use different rounding modes"); + } + + @Test + @DisplayName("FIXED: Real-world multi-tenant sequence now works correctly") + void testRealWorldMultiTenantSequence() { + // Initialize tenants with different rounding modes + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantB", 6); // HALF_EVEN + + // Scenario: Tenant A processes a loan EMI calculation + ThreadLocalContextUtil.setTenant(tenantA); + RoundingMode tenantAMode = MoneyHelper.getRoundingMode(); + MathContext tenantAContext = MoneyHelper.getMathContext(); + + // Simulate loan calculation with tenant-specific MathContext + MonetaryCurrency currency = new MonetaryCurrency("USD", 2, null); + Money principalAmount = Money.of(currency, new BigDecimal("1000.00"), tenantAContext); + Money tenantAEMI = principalAmount.dividedBy(BigDecimal.valueOf(3), tenantAContext); // 1000 / 3 = 333.333... + + // Scenario: Tenant B processes their loan EMI calculation immediately after + ThreadLocalContextUtil.setTenant(tenantB); + RoundingMode tenantBMode = MoneyHelper.getRoundingMode(); + MathContext tenantBContext = MoneyHelper.getMathContext(); + + // Tenant B's calculation uses their own rounding mode + Money tenantBPrincipal = Money.of(currency, new BigDecimal("1000.00"), tenantBContext); + Money tenantBEMI = tenantBPrincipal.dividedBy(BigDecimal.valueOf(3), tenantBContext); // 1000 / 3 = 333.333... + + // VERIFY: Tenants now use different configurations (BUG FIXED) + assertNotEquals(tenantAMode, tenantBMode, "FIXED: Tenant B now uses their own rounding mode"); + assertNotEquals(tenantAContext.getRoundingMode(), tenantBContext.getRoundingMode(), + "FIXED: Tenants use different MathContext rounding modes"); + } + + @Test + @DisplayName("FIXED: Compliance risk scenario now works correctly") + void testComplianceRiskScenarioFixed() { + // Initialize tenants with different regulatory requirements + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // US regulations: HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantB", 3); // EU regulations: FLOOR + + // US tenant follows US banking regulations (HALF_UP) + ThreadLocalContextUtil.setTenant(tenantA); // US tenant + RoundingMode usRoundingMode = MoneyHelper.getRoundingMode(); + + // EU tenant should follow EU banking regulations (FLOOR) + ThreadLocalContextUtil.setTenant(tenantB); // EU tenant + RoundingMode euRoundingMode = MoneyHelper.getRoundingMode(); + + // COMPLIANCE SUCCESS: Each tenant uses their correct rounding rules + assertEquals(RoundingMode.HALF_UP, usRoundingMode, "US tenant uses HALF_UP"); + assertEquals(RoundingMode.FLOOR, euRoundingMode, "EU tenant uses FLOOR"); + + // VERIFY: No more compliance violations + assertNotEquals(usRoundingMode, euRoundingMode, "FIXED: EU tenant now uses correct regulatory rounding mode"); + } + + @Test + @DisplayName("CACHE: Cache invalidation works correctly") + void testCacheInvalidation() { + // Initialize tenant with HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + + // Tenant A gets initial configuration + ThreadLocalContextUtil.setTenant(tenantA); + RoundingMode initialMode = MoneyHelper.getRoundingMode(); + assertEquals(RoundingMode.HALF_UP, initialMode); + + // Update tenant configuration to HALF_EVEN + MoneyHelper.updateTenantRoundingMode("tenantA", 6); // HALF_EVEN + + // Tenant A should now get updated configuration + RoundingMode updatedMode = MoneyHelper.getRoundingMode(); + assertEquals(RoundingMode.HALF_EVEN, updatedMode); + + // Verify cache was properly invalidated + assertNotEquals(initialMode, updatedMode, "Cache update should allow configuration changes"); + } + + @Test + @DisplayName("PERFORMANCE: Tenant-keyed cache maintains performance benefits") + void testPerformanceBenefits() { + // Initialize tenant + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + + ThreadLocalContextUtil.setTenant(tenantA); + + // First call should cache the value + long startTime = System.nanoTime(); + RoundingMode mode1 = MoneyHelper.getRoundingMode(); + long firstCallTime = System.nanoTime() - startTime; + + // Subsequent calls should be faster (cached) + startTime = System.nanoTime(); + RoundingMode mode2 = MoneyHelper.getRoundingMode(); + long secondCallTime = System.nanoTime() - startTime; + + // Verify same result and expect second call to be faster + assertEquals(mode1, mode2, "Cached result should be consistent"); + } + + @Test + @DisplayName("DEMONSTRATION: Specific calculation showing rounding differences") + void testCalculationWithActualRoundingDifferences() { + // Initialize tenants with different rounding modes + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + MoneyHelper.initializeTenantRoundingMode("tenantB", 1); // DOWN + + // Test with a value that demonstrates clear rounding difference + MonetaryCurrency currency = new MonetaryCurrency("USD", 2, null); + BigDecimal testAmount = new BigDecimal("2.556"); // .556 will round differently + + // Tenant A: HALF_UP + ThreadLocalContextUtil.setTenant(tenantA); + MathContext tenantAContext = MoneyHelper.getMathContext(); + Money tenantAMoney = Money.of(currency, testAmount, tenantAContext); + + // Tenant B: DOWN + ThreadLocalContextUtil.setTenant(tenantB); + MathContext tenantBContext = MoneyHelper.getMathContext(); + Money tenantBMoney = Money.of(currency, testAmount, tenantBContext); + + // The money creation itself applies rounding based on currency decimal places + // For USD with 2 decimal places: 2.556 -> HALF_UP gives 2.56, DOWN gives 2.55 + + // Verify that the fix provides proper tenant isolation + assertNotEquals(tenantAContext.getRoundingMode(), tenantBContext.getRoundingMode(), "Tenants use different rounding modes"); + assertEquals(RoundingMode.HALF_UP, tenantAContext.getRoundingMode()); + assertEquals(RoundingMode.DOWN, tenantBContext.getRoundingMode()); + } + + @Test + @DisplayName("EXCEPTION: Should throw IllegalStateException when no tenant context is available") + void testThrowsExceptionWhenNoTenantContext() { + // Clear any existing tenant context + ThreadLocalContextUtil.setTenant(null); + + // Test getRoundingMode throws exception + IllegalStateException roundingModeException = assertThrows(IllegalStateException.class, () -> MoneyHelper.getRoundingMode(), + "Expected IllegalStateException when no tenant context is available for getRoundingMode"); + + assertEquals("No tenant context available. MoneyHelper requires a valid tenant context to ensure proper multi-tenant isolation.", + roundingModeException.getMessage()); + + // Test getMathContext throws exception + IllegalStateException mathContextException = assertThrows(IllegalStateException.class, () -> MoneyHelper.getMathContext(), + "Expected IllegalStateException when no tenant context is available for getMathContext"); + + assertEquals("No tenant context available. MoneyHelper requires a valid tenant context to ensure proper multi-tenant isolation.", + mathContextException.getMessage()); + } + + @Test + @DisplayName("UTILITY: Test utility methods for tenant management") + void testUtilityMethods() { + // Initially no tenants should be initialized + assertTrue(MoneyHelper.getInitializedTenants().isEmpty(), "No tenants should be initialized initially"); + assertFalse(MoneyHelper.isTenantInitialized("tenantA"), "Tenant A should not be initialized"); + + // Initialize tenant A + MoneyHelper.initializeTenantRoundingMode("tenantA", 4); // HALF_UP + + // Verify tenant A is now initialized + assertTrue(MoneyHelper.isTenantInitialized("tenantA"), "Tenant A should be initialized"); + Set<String> initializedTenants = MoneyHelper.getInitializedTenants(); + assertEquals(1, initializedTenants.size(), "One tenant should be initialized"); + assertTrue(initializedTenants.contains("tenantA"), "Tenant A should be in initialized tenants"); + + // Initialize tenant B + MoneyHelper.initializeTenantRoundingMode("tenantB", 6); // HALF_EVEN + + // Verify both tenants are initialized + assertTrue(MoneyHelper.isTenantInitialized("tenantB"), "Tenant B should be initialized"); + initializedTenants = MoneyHelper.getInitializedTenants(); + assertEquals(2, initializedTenants.size(), "Two tenants should be initialized"); + assertTrue(initializedTenants.contains("tenantB"), "Tenant B should be in initialized tenants"); + + // Clear cache for tenant A + MoneyHelper.clearCacheForTenant("tenantA"); + + // Verify tenant A is no longer initialized + assertFalse(MoneyHelper.isTenantInitialized("tenantA"), "Tenant A should not be initialized after clearing cache"); + assertTrue(MoneyHelper.isTenantInitialized("tenantB"), "Tenant B should still be initialized"); + } + + @Test + @DisplayName("VALIDATION: Invalid rounding mode values should throw exception") + void testInvalidRoundingModeValidation() { + // Test invalid rounding mode values + IllegalArgumentException tooLowException = assertThrows(IllegalArgumentException.class, + () -> MoneyHelper.initializeTenantRoundingMode("tenantA", -1), + "Expected IllegalArgumentException for negative rounding mode"); + + assertTrue(tooLowException.getMessage().contains("Invalid rounding mode value: -1"), + "Exception message should indicate invalid rounding mode"); + + IllegalArgumentException tooHighException = assertThrows(IllegalArgumentException.class, + () -> MoneyHelper.initializeTenantRoundingMode("tenantA", 7), "Expected IllegalArgumentException for rounding mode > 6"); + + assertTrue(tooHighException.getMessage().contains("Invalid rounding mode value: 7"), + "Exception message should indicate invalid rounding mode"); + + // Test null tenant identifier + IllegalArgumentException nullTenantException = assertThrows(IllegalArgumentException.class, + () -> MoneyHelper.initializeTenantRoundingMode(null, 4), "Expected IllegalArgumentException for null tenant identifier"); + + assertEquals("Tenant identifier cannot be null", nullTenantException.getMessage()); + } + + @Test + @DisplayName("UTILITY: createMathContext should work without tenant context") + void testCreateMathContextUtilityMethod() { + // This method should work without tenant context + ThreadLocalContextUtil.setTenant(null); + + // Test creating MathContext with different rounding modes + MathContext halfUpContext = MoneyHelper.createMathContext(RoundingMode.HALF_UP); + MathContext halfEvenContext = MoneyHelper.createMathContext(RoundingMode.HALF_EVEN); + + // Verify contexts are created correctly + assertEquals(MoneyHelper.PRECISION, halfUpContext.getPrecision(), "Precision should be correct"); + assertEquals(RoundingMode.HALF_UP, halfUpContext.getRoundingMode(), "Rounding mode should be HALF_UP"); + assertEquals(RoundingMode.HALF_EVEN, halfEvenContext.getRoundingMode(), "Rounding mode should be HALF_EVEN"); + + // Verify they are different + assertNotEquals(halfUpContext, halfEvenContext, "Different rounding modes should create different contexts"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/api/InternalConfigurationsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/api/InternalConfigurationsApiResource.java index 127b4c3914..cec0e3fec8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/api/InternalConfigurationsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/api/InternalConfigurationsApiResource.java @@ -30,8 +30,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationProperty; import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper; +import org.apache.fineract.infrastructure.configuration.service.MoneyHelperInitializationService; import org.apache.fineract.infrastructure.core.boot.FineractProfiles; -import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -44,6 +46,7 @@ import org.springframework.stereotype.Component; public class InternalConfigurationsApiResource implements InitializingBean { private final GlobalConfigurationRepositoryWrapper repository; + private final MoneyHelperInitializationService moneyHelperInitializationService; @Override @SuppressFBWarnings("SLF4J_SIGN_ONLY_FORMAT") @@ -75,7 +78,14 @@ public class InternalConfigurationsApiResource implements InitializingBean { repository.save(config); log.warn("Config {} updated to {}", config.getName(), config.getValue()); repository.removeFromCache(config.getName()); - MoneyHelper.fetchRoundingModeFromGlobalConfig(); + + // Update MoneyHelper when rounding mode configuration changes + if (GlobalConfigurationConstants.ROUNDING_MODE.equals(configName) && configValue != null) { + FineractPlatformTenant currentTenant = ThreadLocalContextUtil.getTenant(); + if (currentTenant != null) { + moneyHelperInitializationService.initializeTenantRoundingMode(currentTenant); + } + } return Response.status(Response.Status.OK).build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperInitializationService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperInitializationService.java new file mode 100644 index 0000000000..617ef4a28d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperInitializationService.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.configuration.service; + +import java.math.RoundingMode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationProperty; +import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.springframework.stereotype.Service; + +/** + * Service to initialize MoneyHelper configurations for multi-tenant environments. This service bridges the gap between + * global configuration and MoneyHelper's tenant-specific caching. + * + * Note: MoneyHelper rounding mode is immutable once initialized to maintain data integrity. Updates require application + * restart to take effect. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class MoneyHelperInitializationService { + + private final GlobalConfigurationRepositoryWrapper globalConfigurationRepository; + + /** + * Initialize MoneyHelper for a specific tenant. This method should be called during tenant setup and whenever + * rounding mode configuration changes. + * + * @param tenant + * the tenant to initialize + */ + public void initializeTenantRoundingMode(FineractPlatformTenant tenant) { + if (tenant == null) { + throw new IllegalArgumentException("Tenant cannot be null"); + } + + String tenantIdentifier = tenant.getTenantIdentifier(); + + try { + // Set tenant context to read configuration + ThreadLocalContextUtil.setTenant(tenant); + + // Get rounding mode from configuration with fallback to default + int roundingModeValue = getRoundingModeFromConfiguration(); + + // Initialize MoneyHelper for this tenant + MoneyHelper.initializeTenantRoundingMode(tenantIdentifier, roundingModeValue); + + log.info("MoneyHelper initialized for tenant '{}' with rounding mode: {}", tenantIdentifier, roundingModeValue); + + } catch (Exception e) { + log.error("Failed to initialize MoneyHelper for tenant '{}'", tenantIdentifier, e); + throw new RuntimeException("Failed to initialize MoneyHelper for tenant: " + tenantIdentifier, e); + } finally { + // Clear tenant context + ThreadLocalContextUtil.clearTenant(); + } + } + + /** + * Check if MoneyHelper is initialized for a tenant. + * + * @param tenantIdentifier + * the tenant identifier + * @return true if initialized, false otherwise + */ + public boolean isTenantInitialized(String tenantIdentifier) { + return MoneyHelper.isTenantInitialized(tenantIdentifier); + } + + /** + * Get the rounding mode from configuration with fallback to default. This method safely handles cases where + * configuration might not be available. + * + * @return the rounding mode value + */ + private int getRoundingModeFromConfiguration() { + try { + GlobalConfigurationProperty roundingModeProperty = globalConfigurationRepository + .findOneByNameWithNotFoundDetection(GlobalConfigurationConstants.ROUNDING_MODE); + + if (roundingModeProperty != null && roundingModeProperty.getValue() != null) { + return roundingModeProperty.getValue().intValue(); + } + } catch (Exception e) { + log.warn("Failed to read rounding mode from configuration: {}", e.getMessage()); + } + + // Default to HALF_UP if configuration is not available + int defaultRoundingMode = RoundingMode.HALF_UP.ordinal(); + log.info("Using default rounding mode: {} (HALF_UP)", defaultRoundingMode); + return defaultRoundingMode; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperStartupInitializationService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperStartupInitializationService.java new file mode 100644 index 0000000000..7215d9616d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/service/MoneyHelperStartupInitializationService.java @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.configuration.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; + +/** + * Service to initialize MoneyHelper for all tenants during application startup. This service runs after the application + * is fully started to ensure all database migrations and tenant configurations are complete. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class MoneyHelperStartupInitializationService { + + private final TenantDetailsService tenantDetailsService; + private final MoneyHelperInitializationService moneyHelperInitializationService; + + /** + * Initialize MoneyHelper for all tenants after the application is ready. This method runs after + * ApplicationReadyEvent to ensure all database migrations and tenant configurations are complete. + */ + @EventListener(ApplicationReadyEvent.class) + @Order(1000) // Run after other startup processes + public void initializeMoneyHelperForAllTenants() { + log.info("Starting MoneyHelper initialization for all tenants..."); + + try { + List<FineractPlatformTenant> tenants = tenantDetailsService.findAllTenants(); + + if (tenants.isEmpty()) { + log.warn("No tenants found during MoneyHelper initialization"); + return; + } + + int successCount = 0; + int failureCount = 0; + + for (FineractPlatformTenant tenant : tenants) { + try { + String tenantIdentifier = tenant.getTenantIdentifier(); + + // Check if already initialized (in case of restart scenarios) + if (moneyHelperInitializationService.isTenantInitialized(tenantIdentifier)) { + log.debug("MoneyHelper already initialized for tenant: {}", tenantIdentifier); + successCount++; + continue; + } + + // Initialize MoneyHelper for this tenant + moneyHelperInitializationService.initializeTenantRoundingMode(tenant); + successCount++; + + } catch (Exception e) { + failureCount++; + log.error("Failed to initialize MoneyHelper for tenant '{}'", tenant.getTenantIdentifier(), e); + } + } + + log.info("MoneyHelper initialization completed - Success: {}, Failures: {}, Total: {}", successCount, failureCount, + tenants.size()); + + if (failureCount > 0) { + log.warn("Some tenants failed MoneyHelper initialization. " + + "These tenants may experience issues with rounding mode configuration."); + } + + } catch (Exception e) { + log.error("Critical error during MoneyHelper initialization for all tenants", e); + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java index 6fde5c64bb..a8ab8a1cba 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -34,14 +34,12 @@ import static org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDat import static org.apache.fineract.util.TimeZoneConstants.ASIA_MANILA_ID; import static org.apache.fineract.util.TimeZoneConstants.EUROPE_BERLIN_ID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.util.List; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.junit.context.WithTenantContext; import org.apache.fineract.junit.context.WithTenantContextExtension; import org.apache.fineract.junit.system.WithSystemProperty; @@ -59,8 +57,6 @@ import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith({ WithSystemTimeZoneExtension.class, WithTenantContextExtension.class, WithSystemPropertyExtension.class }) public class DefaultScheduledDateGeneratorTest { @@ -69,12 +65,8 @@ public class DefaultScheduledDateGeneratorTest { @BeforeEach public void setUp() { - ConfigurationDomainService cds = Mockito.mock(ConfigurationDomainService.class); - given(cds.getRoundingMode()).willReturn(6); // default - - MoneyHelper moneyHelper = new MoneyHelper(); - ReflectionTestUtils.setField(moneyHelper, "configurationDomainService", cds); - moneyHelper.initialize(); + // Initialize MoneyHelper with default rounding mode (HALF_EVEN = 6) + MoneyHelper.initializeTenantRoundingMode("default", 6); } @Test diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java index 13a5a87254..b3c3ab213d 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java @@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.loanaccount.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -32,10 +31,10 @@ import java.util.Map; import java.util.Set; import org.apache.fineract.commands.service.CommandProcessingService; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; -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.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; @@ -65,10 +64,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; -import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class LoanWritePlatformServiceJpaRepositoryImplTest { @@ -134,12 +131,12 @@ public class LoanWritePlatformServiceJpaRepositoryImplTest { } private void setupMoneyHelper() { - ConfigurationDomainService cds = Mockito.mock(ConfigurationDomainService.class); - lenient().when(cds.getRoundingMode()).thenReturn(6); + // Set up a test tenant context + FineractPlatformTenant tenant = new FineractPlatformTenant(1L, "test", "Test Tenant", "Asia/Kolkata", null); + ThreadLocalContextUtil.setTenant(tenant); - MoneyHelper moneyHelper = new MoneyHelper(); - ReflectionTestUtils.setField(moneyHelper, "configurationDomainService", cds); - moneyHelper.initialize(); + // Initialize MoneyHelper with tenant configuration (HALF_EVEN = 6) + MoneyHelper.initializeTenantRoundingMode("test", 6); } @Test
