FINERACT-2181: Moneyhelper multitenant configuration fix, rework it to be a utility class
This commit is contained in:
+149
-27
@@ -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;
|
||||
|
||||
// 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;
|
||||
// Private constructor to prevent instantiation
|
||||
private MoneyHelper() {
|
||||
throw new UnsupportedOperationException("MoneyHelper is a utility class and cannot be instantiated");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
+347
@@ -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");
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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();
|
||||
}
|
||||
|
||||
+115
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -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
|
||||
|
||||
+6
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user