PortfolioValueValidator.java

package com.taxonomy.portfolio.service;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Currency;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

/** Shared validation for persisted portfolio values whose database constraints are not descriptive enough. */
final class PortfolioValueValidator {

    static final int MAX_SOURCE_FRAGMENT_IDS = 1_000;
    static final int MAX_ORIGINAL_TEXT_CHARACTERS = 100_000;
    static final int MAX_EXTENSION_ATTRIBUTES = 100;
    static final int MAX_EXTENSION_KEY_CHARACTERS = 120;
    static final int MAX_EXTENSION_VALUE_CHARACTERS = 1_000;
    static final Duration MAX_VERIFICATION_CLOCK_SKEW = Duration.ofMinutes(5);

    private static final int MONEY_INTEGER_DIGITS = 17;
    private static final int MONEY_SCALE = 2;

    private PortfolioValueValidator() {
    }

    static BigDecimal money(BigDecimal value, String field) {
        if (value == null) return null;
        if (value.signum() < 0) {
            throw PortfolioException.validation(field + " must not be negative");
        }

        BigDecimal normalized = value.stripTrailingZeros();
        if (normalized.scale() < 0) normalized = normalized.setScale(0);
        if (normalized.scale() > MONEY_SCALE) {
            throw PortfolioException.validation(field + " supports at most " + MONEY_SCALE + " decimal places");
        }
        int integerDigits = Math.max(0, normalized.precision() - normalized.scale());
        if (integerDigits > MONEY_INTEGER_DIGITS) {
            throw PortfolioException.validation(
                    field + " supports at most " + MONEY_INTEGER_DIGITS + " integer digits");
        }
        return normalized;
    }

    static String currency(String value, String field) {
        if (value == null || value.isBlank()) return null;
        String normalized = value.strip().toUpperCase(Locale.ROOT);
        try {
            return Currency.getInstance(normalized).getCurrencyCode();
        } catch (IllegalArgumentException failure) {
            throw PortfolioException.validation(field + " must be a registered ISO 4217 currency code");
        }
    }

    static void requireMoneyPair(BigDecimal amount,
                                 String currency,
                                 String amountField,
                                 String currencyField) {
        if ((amount == null) != (currency == null)) {
            throw PortfolioException.validation(
                    amountField + " and " + currencyField + " must be supplied together");
        }
    }

    static Instant verifiedAt(Instant value, Instant now, boolean required) {
        if (value == null) {
            if (required) throw PortfolioException.validation("verifiedAt is required for product claims");
            return null;
        }
        Instant latestPlausible = now.plus(MAX_VERIFICATION_CLOCK_SKEW);
        if (value.isAfter(latestPlausible)) {
            throw PortfolioException.validation(
                    "verifiedAt must not be more than five minutes in the future");
        }
        return value;
    }

    static Integer nonNegativeDays(Integer value, String field) {
        if (value == null) return null;
        if (value < 0 || value > 36_500) {
            throw PortfolioException.validation(field + " must be between 0 and 36500 days");
        }
        return value;
    }

    static Map<String, String> extensionAttributes(Map<String, String> attributes) {
        if (attributes == null) return null;
        if (attributes.size() > MAX_EXTENSION_ATTRIBUTES) {
            throw PortfolioException.validation(
                    "extensionAttributes contains more than " + MAX_EXTENSION_ATTRIBUTES + " entries");
        }

        Map<String, String> normalized = new LinkedHashMap<>();
        attributes.forEach((key, value) -> {
            String normalizedKey = ProjectPortfolioService.requireText(
                    key, "extensionAttributes key", MAX_EXTENSION_KEY_CHARACTERS);
            String normalizedValue = ProjectPortfolioService.limited(
                    value, MAX_EXTENSION_VALUE_CHARACTERS,
                    "extensionAttributes[" + normalizedKey + "]");
            if (normalizedValue == null) {
                throw PortfolioException.validation(
                        "extensionAttributes[" + normalizedKey + "] must not be null");
            }
            if (normalized.putIfAbsent(normalizedKey, normalizedValue) != null) {
                throw PortfolioException.validation(
                        "extensionAttributes contains duplicate key after normalization: "
                                + normalizedKey);
            }
        });
        return Map.copyOf(normalized);
    }
}