package com.test.payment.service; import com.test.payment.dto.PaymentRequest; import com.test.payment.dto.ProviderLimitDto; import com.test.payment.exceptions.PaymentLimitExceededException; import com.test.payment.models.LimitPeriod; import com.test.payment.models.LimitScope; import com.test.payment.models.PaymentProviderType; import com.test.payment.models.ProviderLimit; import com.test.payment.repository.PaymentInitiationRepository; import com.test.payment.repository.ProviderLimitRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; /** * The blocking, transactional half of {@link PaymentLimitService} — see * {@link PaymentLifecycleStore} for why the split exists. */ @Service @RequiredArgsConstructor @Slf4j public class PaymentLimitStore { private final ProviderLimitRepository limitRepository; private final PaymentInitiationRepository initiationRepository; private final StatusCatalog statuses; /** * Returns normally when the request is within every active limit; throws * PaymentLimitExceededException on the first breach. */ @Transactional(readOnly = true) public void enforce(PaymentProviderType provider, PaymentRequest request) { BigDecimal amount = BigDecimal.valueOf(request.getAmount()); LocalDateTime now = LocalDateTime.now(); for (ProviderLimit limit : limitRepository.findByProviderAndActiveTrue(provider)) { check(limit, provider, request.getPhoneNumber(), amount, now); } } @Transactional(readOnly = true) public List list(PaymentProviderType provider) { return provider == null ? limitRepository.findAllByOrderByProviderAscPeriodAsc() : limitRepository.findByProviderOrderByPeriod(provider); } /** * Creates or updates the ceiling for one provider/period/scope triple. * Scope defaults to PER_PAYER when the caller omits it. */ @Transactional public ProviderLimit upsert(ProviderLimitDto dto) { PaymentProviderType provider = parseProvider(dto.getProvider()); String period = dto.getPeriod().toUpperCase(); String scope = dto.getScope() == null ? LimitScope.PER_PAYER.name() : dto.getScope().toUpperCase(); validateNames(period, scope); LocalDateTime now = LocalDateTime.now(); ProviderLimit limit = limitRepository.findByProviderAndPeriodAndScope(provider, period, scope) .orElseGet(() -> ProviderLimit.builder() .Provider(provider) .Period(period) .Scope(scope) .CreatedAt(now) .build()); limit.setMaxAmount(dto.getMaxAmount()); limit.setCurrency(dto.getCurrency() != null ? dto.getCurrency() : limit.getCurrency()); limit.setActive(dto.getActive() == null ? Boolean.TRUE : dto.getActive()); limit.setUpdatedAt(now); ProviderLimit saved = limitRepository.save(limit); log.info("Provider limit set: {} {} = {}", saved.getProvider(), saved.getPeriod(), saved.getMaxAmount()); return saved; } private void check(ProviderLimit limit, PaymentProviderType provider, String phoneNumber, BigDecimal amount, LocalDateTime now) { LimitPeriod period = parsePeriod(limit); if (period == null) { return; } if (!period.isCumulative()) { if (amount.compareTo(limit.getMaxAmount()) > 0) { throw breach(limit, provider, amount, BigDecimal.ZERO); } return; } LocalDateTime since = period.windowStart(now); BigDecimal alreadySpent = scopeOf(limit) == LimitScope.MERCHANT ? initiationRepository.sumAmountInWindowForProvider(provider, since, statuses.failedName()) : initiationRepository.sumAmountInWindow(provider, phoneNumber, since, statuses.failedName()); if (alreadySpent == null) { alreadySpent = BigDecimal.ZERO; } if (alreadySpent.add(amount).compareTo(limit.getMaxAmount()) > 0) { throw breach(limit, provider, amount, alreadySpent); } } private PaymentLimitExceededException breach(ProviderLimit limit, PaymentProviderType provider, BigDecimal amount, BigDecimal alreadySpent) { String currency = limit.getCurrency() == null ? "" : limit.getCurrency() + " "; String window = LimitPeriod.valueOf(limit.getPeriod()).isCumulative() ? limit.getPeriod() + " " + scopeOf(limit) : limit.getPeriod(); String detail = alreadySpent.signum() > 0 ? " (%s%s already used in this period)".formatted(currency, alreadySpent.toPlainString()) : ""; String message = "%s %s limit exceeded: requested %s%s against a maximum of %s%s%s".formatted( provider, window, currency, amount.toPlainString(), currency, limit.getMaxAmount().toPlainString(), detail); log.info("Rejecting payment — {}", message); return new PaymentLimitExceededException(message); } /** Parses the market-qualified provider from a request body, e.g. "airtel_ke". */ private PaymentProviderType parseProvider(String value) { try { return PaymentProviderType.valueOf(value.trim().toUpperCase()); } catch (IllegalArgumentException | NullPointerException ex) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "provider must be one of " + Arrays.toString(PaymentProviderType.values())); } } private void validateNames(String period, String scope) { try { LimitPeriod.valueOf(period); LimitScope.valueOf(scope); } catch (IllegalArgumentException ex) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "provider must be one of %s, period one of %s and scope one of %s".formatted( Arrays.toString(PaymentProviderType.values()), Arrays.toString(LimitPeriod.values()), Arrays.toString(LimitScope.values()))); } } /** * An unset or unrecognised scope falls back to PER_PAYER — the tighter of the two, * so a bad row can never silently widen a ceiling. */ private LimitScope scopeOf(ProviderLimit limit) { try { return LimitScope.valueOf(limit.getScope()); } catch (IllegalArgumentException | NullPointerException ex) { return LimitScope.PER_PAYER; } } /** * A row whose period no longer maps to a LimitPeriod constant is skipped rather * than failing every payment for that provider. */ private LimitPeriod parsePeriod(ProviderLimit limit) { try { return LimitPeriod.valueOf(limit.getPeriod()); } catch (IllegalArgumentException | NullPointerException ex) { log.warn("Ignoring provider limit {} with unknown period '{}'", limit.getId(), limit.getPeriod()); return null; } } }