This commit is contained in:
spiro-alvin-nyasimi
2026-08-17 16:20:13 +03:00
parent c6a6755b08
commit cd186868fa
75 changed files with 3608 additions and 870 deletions

View File

@@ -0,0 +1,181 @@
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<ProviderLimit> 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;
}
}
}