182 lines
7.5 KiB
Java
182 lines
7.5 KiB
Java
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;
|
|
}
|
|
}
|
|
}
|