Updates
This commit is contained in:
@@ -37,6 +37,7 @@ Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service (
|
||||
- `POST /callback` — provider result callback (MTN also accepts PUT); stored 1:1 with the initiation, deduplicated, updates status. Always acks.
|
||||
- `GET /status/{providerReference}` — DB state; if still PENDING, performs a live provider status query and updates the DB. Degrades to last-known state when the provider rate-limits.
|
||||
- `GET /api/payments/transactions?provider=` — consolidated transactions across providers.
|
||||
- `GET /api/payments/limits?provider=` / `PUT /api/payments/limits` — read and upsert the configurable payment ceilings (body: `provider`, `period`, `scope`, `maxAmount`, `currency`, `active`).
|
||||
|
||||
**Provider specifics:**
|
||||
- M-Pesa: `providerReference` = CheckoutRequestID, `secondaryReference` = MerchantRequestID; STK query "still processing" (errorCode 500.001.1001) maps to `ProviderProcessingException` → stays PENDING. Sandbox creds in yml are live.
|
||||
@@ -60,6 +61,7 @@ Spring Boot 4 / Java 21 reactive (WebFlux) multi-provider mobile-money service (
|
||||
- `PAYMENT_CALLBACKS` — the provider result callback, `initiation_id UNIQUE`, duplicates ignored, raw payload stored as JSON.
|
||||
- `TRANSACTIONS` — consolidated record upserted by `PaymentLifecycleService.recordTransaction` whenever an initiation reaches a terminal state, from whichever path resolved it (`resolvedBy`: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION). `initiation_id UNIQUE`.
|
||||
- `PROVIDER_TOKENS` — OAuth tokens per provider with expiry.
|
||||
- `PROVIDER_LIMITS` — configurable payment ceilings, `UNIQUE (provider, period, scope)`. Periods come from the `LimitPeriod` enum (PER_TRANSACTION, DAILY, MONTHLY — adding a constant is all a new period needs); `scope` from the `LimitScope` enum (PER_PAYER buckets by paying MSISDN, MERCHANT sums every payer on the provider; defaults to PER_PAYER and is ignored by PER_TRANSACTION). Defaults are seeded by `DatabaseSchema.SEED_STATEMENTS` only when the triple is absent, so runtime edits survive on a persistent DB. `PaymentLimitService.enforce` runs in every `<Provider>Service.initiatePayment` **before** the initiation is persisted (breaches leave no DB row) and raises `PaymentLimitExceededException` → 422 `LIMIT_EXCEEDED`. Cumulative periods sum non-FAILED initiations inside the window (per MSISDN for PER_PAYER, provider-wide for MERCHANT), so PENDING pushes count against the cap. An unrecognised `scope` on a row falls back to PER_PAYER (the tighter interpretation).
|
||||
- `@Table` names must be UPPERCASE — H2 stores unquoted DDL identifiers uppercase and Spring Data quotes entity names verbatim.
|
||||
- `PaymentReconciliationJob` reconciles PENDING initiations of **all** providers older than `payments.reconciliation.pending-age` (default 5m) by dispatching to the right `PaymentProviderService`; interval `payments.reconciliation.fixed-delay` (default 60s).
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public class DatabaseSchemaInitializer {
|
||||
return () -> Flux.fromIterable(DatabaseSchema.STATEMENTS)
|
||||
.concatMap(statement -> databaseClient.sql(statement).then())
|
||||
.doOnComplete(() -> log.info("Database schema initialized ({} statements)", DatabaseSchema.STATEMENTS.size()))
|
||||
.thenMany(Flux.fromIterable(DatabaseSchema.SEED_STATEMENTS))
|
||||
.concatMap(statement -> databaseClient.sql(statement).then())
|
||||
.doOnComplete(() -> log.info("Default provider limits seeded"))
|
||||
.then()
|
||||
.block();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.dto.ErrorResponseDto;
|
||||
import com.test.payment.exceptions.PaymentLimitExceededException;
|
||||
import com.test.payment.exceptions.ProviderBusyException;
|
||||
import com.test.payment.exceptions.ProviderPermanentException;
|
||||
import com.test.payment.exceptions.ProviderTransientException;
|
||||
@@ -39,6 +40,12 @@ public class GlobalExceptionHandler {
|
||||
return ErrorResponseDto.of("RATE_LIMITED", "Too many payment requests, please retry shortly");
|
||||
}
|
||||
|
||||
@ExceptionHandler(PaymentLimitExceededException.class)
|
||||
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
public ErrorResponseDto limitExceeded(PaymentLimitExceededException ex) {
|
||||
return ErrorResponseDto.of("LIMIT_EXCEEDED", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ProviderBusyException.class)
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public ErrorResponseDto providerBusy(ProviderBusyException ex) {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package com.test.payment.controller;
|
||||
|
||||
import com.test.payment.dto.ProviderLimitDto;
|
||||
import com.test.payment.models.ProviderLimit;
|
||||
import com.test.payment.models.Transaction;
|
||||
import com.test.payment.service.PaymentLifecycleService;
|
||||
import com.test.payment.service.PaymentLimitService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payments")
|
||||
@@ -15,6 +22,7 @@ import reactor.core.publisher.Flux;
|
||||
public class PaymentsController {
|
||||
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final PaymentLimitService limits;
|
||||
|
||||
/**
|
||||
* All consolidated transactions across providers; optional ?provider=MPESA|AIRTEL|MTN filter.
|
||||
@@ -23,4 +31,20 @@ public class PaymentsController {
|
||||
public Flux<Transaction> transactions(@RequestParam(required = false) String provider) {
|
||||
return lifecycle.listTransactions(provider == null ? null : provider.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured payment ceilings; optional ?provider=MPESA|AIRTEL|MTN filter.
|
||||
*/
|
||||
@GetMapping("/limits")
|
||||
public Flux<ProviderLimit> limits(@RequestParam(required = false) String provider) {
|
||||
return limits.list(provider == null ? null : provider.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the ceiling for one provider/period pair, e.g. a MONTHLY cap.
|
||||
*/
|
||||
@PutMapping("/limits")
|
||||
public Mono<ProviderLimit> setLimit(@Valid @RequestBody ProviderLimitDto request) {
|
||||
return limits.upsert(request);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/main/java/com/test/payment/dto/ProviderLimitDto.java
Normal file
33
src/main/java/com/test/payment/dto/ProviderLimitDto.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.test.payment.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Upsert body for a configurable provider ceiling
|
||||
* (PUT /api/payments/limits).
|
||||
*/
|
||||
@Data
|
||||
public class ProviderLimitDto {
|
||||
|
||||
@NotBlank(message = "provider is required")
|
||||
private String provider;
|
||||
|
||||
@NotBlank(message = "period is required")
|
||||
private String period;
|
||||
|
||||
/** PER_PAYER (default) or MERCHANT. */
|
||||
private String scope;
|
||||
|
||||
@NotNull(message = "maxAmount is required")
|
||||
@DecimalMin(value = "0.01", message = "maxAmount must be greater than 0")
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
private String currency;
|
||||
|
||||
private Boolean active;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.test.payment.exceptions;
|
||||
|
||||
/**
|
||||
* The request breaches a configured PROVIDER_LIMITS ceiling. Raised before the
|
||||
* payment is persisted or sent to the provider.
|
||||
*/
|
||||
public class PaymentLimitExceededException extends RuntimeException {
|
||||
public PaymentLimitExceededException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,53 @@ public final class DatabaseSchema {
|
||||
updated_at TIMESTAMP,
|
||||
CONSTRAINT fk_transaction_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id)
|
||||
)
|
||||
""",
|
||||
// Configurable payment ceilings: one row per provider per period
|
||||
// (PER_TRANSACTION, DAILY, MONTHLY — see LimitPeriod) per scope
|
||||
// (PER_PAYER, MERCHANT — see LimitScope).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS provider_limits (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
period VARCHAR(20) NOT NULL,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'PER_PAYER',
|
||||
max_amount DECIMAL(14,2) NOT NULL,
|
||||
currency VARCHAR(5),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP,
|
||||
CONSTRAINT uq_provider_limit UNIQUE (provider, period, scope)
|
||||
)
|
||||
"""
|
||||
);
|
||||
|
||||
/**
|
||||
* Default ceilings, inserted only when that provider/period/scope triple is
|
||||
* absent, so limits edited at runtime survive a restart on a persistent database.
|
||||
*/
|
||||
public static final List<String> SEED_STATEMENTS = List.of(
|
||||
seedLimit("MPESA", "PER_TRANSACTION", "PER_PAYER", "250000.00", "KES"),
|
||||
seedLimit("MPESA", "DAILY", "PER_PAYER", "500000.00", "KES"),
|
||||
seedLimit("AIRTEL", "PER_TRANSACTION", "PER_PAYER", "150000.00", "KES"),
|
||||
seedLimit("AIRTEL", "DAILY", "PER_PAYER", "300000.00", "KES"),
|
||||
// MoMo sandbox prices in EUR
|
||||
seedLimit("MTN", "PER_TRANSACTION", "PER_PAYER", "5000.00", "EUR"),
|
||||
seedLimit("MTN", "DAILY", "PER_PAYER", "10000.00", "EUR"),
|
||||
// Aggregate merchant-wide daily exposure, across all payers on the provider
|
||||
seedLimit("MPESA", "DAILY", "MERCHANT", "5000000.00", "KES"),
|
||||
seedLimit("AIRTEL", "DAILY", "MERCHANT", "3000000.00", "KES"),
|
||||
seedLimit("MTN", "DAILY", "MERCHANT", "100000.00", "EUR")
|
||||
);
|
||||
|
||||
private static String seedLimit(String provider, String period, String scope,
|
||||
String maxAmount, String currency) {
|
||||
return """
|
||||
INSERT INTO provider_limits (provider, period, scope, max_amount, currency, active, created_at)
|
||||
SELECT '%s', '%s', '%s', %s, '%s', TRUE, CURRENT_TIMESTAMP FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM provider_limits
|
||||
WHERE provider = '%s' AND period = '%s' AND scope = '%s'
|
||||
)
|
||||
""".formatted(provider, period, scope, maxAmount, currency, provider, period, scope);
|
||||
}
|
||||
}
|
||||
|
||||
41
src/main/java/com/test/payment/models/LimitPeriod.java
Normal file
41
src/main/java/com/test/payment/models/LimitPeriod.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The window a provider limit applies over. PER_TRANSACTION checks the request
|
||||
* amount on its own; the others sum the payer's prior payments over the window.
|
||||
* Adding a new period (WEEKLY, YEARLY, ...) only means adding a constant here —
|
||||
* PaymentLimitService iterates whatever rows exist in PROVIDER_LIMITS.
|
||||
*/
|
||||
public enum LimitPeriod {
|
||||
|
||||
PER_TRANSACTION {
|
||||
@Override
|
||||
public LocalDateTime windowStart(LocalDateTime now) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
DAILY {
|
||||
@Override
|
||||
public LocalDateTime windowStart(LocalDateTime now) {
|
||||
return now.toLocalDate().atStartOfDay();
|
||||
}
|
||||
},
|
||||
MONTHLY {
|
||||
@Override
|
||||
public LocalDateTime windowStart(LocalDateTime now) {
|
||||
return now.toLocalDate().withDayOfMonth(1).atStartOfDay();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start of the accumulation window, or null when the limit applies to a single
|
||||
* transaction rather than to a running total.
|
||||
*/
|
||||
public abstract LocalDateTime windowStart(LocalDateTime now);
|
||||
|
||||
public boolean isCumulative() {
|
||||
return this != PER_TRANSACTION;
|
||||
}
|
||||
}
|
||||
12
src/main/java/com/test/payment/models/LimitScope.java
Normal file
12
src/main/java/com/test/payment/models/LimitScope.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
/**
|
||||
* Who a cumulative limit accumulates over.
|
||||
* PER_PAYER buckets by paying MSISDN (the customer-facing cap operators publish);
|
||||
* MERCHANT sums every payer on the provider (our own aggregate exposure cap).
|
||||
* Ignored by PER_TRANSACTION, which never accumulates.
|
||||
*/
|
||||
public enum LimitScope {
|
||||
PER_PAYER,
|
||||
MERCHANT
|
||||
}
|
||||
34
src/main/java/com/test/payment/models/ProviderLimit.java
Normal file
34
src/main/java/com/test/payment/models/ProviderLimit.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A configurable payment ceiling for one provider over one {@link LimitPeriod}.
|
||||
* Seeded with defaults at startup and editable at runtime via /api/payments/limits.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Table("PROVIDER_LIMITS")
|
||||
public class ProviderLimit {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
private String provider;
|
||||
private String period;
|
||||
private String scope;
|
||||
private BigDecimal maxAmount;
|
||||
private String currency;
|
||||
private Boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -1,14 +1,39 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
import com.test.payment.models.PaymentInitiation;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public interface PaymentInitiationRepository extends ReactiveCrudRepository<PaymentInitiation, Long> {
|
||||
|
||||
Flux<PaymentInitiation> findByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff);
|
||||
|
||||
/**
|
||||
* What this payer has already committed to on this provider inside the window.
|
||||
* FAILED attempts do not count; PENDING ones do, so a burst of in-flight pushes
|
||||
* cannot overshoot the ceiling while their callbacks are outstanding.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payment_initiations
|
||||
WHERE provider = :provider AND phone_number = :phoneNumber
|
||||
AND status <> 'FAILED' AND created_at >= :since
|
||||
""")
|
||||
Mono<BigDecimal> sumAmountInWindow(String provider, String phoneNumber, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* The same running total, aggregated across every payer on the provider —
|
||||
* backs the merchant-wide exposure caps.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payment_initiations
|
||||
WHERE provider = :provider AND status <> 'FAILED' AND created_at >= :since
|
||||
""")
|
||||
Mono<BigDecimal> sumAmountInWindowForProvider(String provider, LocalDateTime since);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.test.payment.repository;
|
||||
|
||||
import com.test.payment.models.ProviderLimit;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface ProviderLimitRepository extends ReactiveCrudRepository<ProviderLimit, Long> {
|
||||
|
||||
Flux<ProviderLimit> findByProviderAndActiveTrue(String provider);
|
||||
|
||||
Flux<ProviderLimit> findByProviderOrderByPeriod(String provider);
|
||||
|
||||
Flux<ProviderLimit> findAllByOrderByProviderAscPeriodAsc();
|
||||
|
||||
Mono<ProviderLimit> findByProviderAndPeriodAndScope(String provider, String period, String scope);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class AirtelService implements PaymentProviderService {
|
||||
|
||||
private final AirtelClient airtelClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final PaymentLimitService limits;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -45,7 +46,8 @@ public class AirtelService implements PaymentProviderService {
|
||||
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
return limits.enforce(provider(), request)
|
||||
.then(lifecycle.saveInitiation(provider(), request))
|
||||
.flatMap(initiation -> Mono.defer(() -> {
|
||||
String reference = "ATL" + UUID.randomUUID().toString().replace("-", "");
|
||||
return airtelClient.pay(buildRequest(request, reference))
|
||||
|
||||
@@ -36,6 +36,7 @@ public class MpesaService implements PaymentProviderService {
|
||||
|
||||
private final MpesaClient mpesaClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final PaymentLimitService limits;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -46,7 +47,8 @@ public class MpesaService implements PaymentProviderService {
|
||||
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
return limits.enforce(provider(), request)
|
||||
.then(lifecycle.saveInitiation(provider(), request))
|
||||
.flatMap(initiation -> Mono.defer(() -> mpesaClient.stkPush(buildStkRequest(request)))
|
||||
.flatMap(response -> lifecycle.persistResponse(initiation, toResponseData(response)))
|
||||
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
|
||||
|
||||
@@ -36,6 +36,7 @@ public class MtnService implements PaymentProviderService {
|
||||
|
||||
private final MtnClient mtnClient;
|
||||
private final PaymentLifecycleService lifecycle;
|
||||
private final PaymentLimitService limits;
|
||||
private final Environment environment;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -46,7 +47,8 @@ public class MtnService implements PaymentProviderService {
|
||||
|
||||
@Override
|
||||
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
|
||||
return lifecycle.saveInitiation(provider(), request)
|
||||
return limits.enforce(provider(), request)
|
||||
.then(lifecycle.saveInitiation(provider(), request))
|
||||
.flatMap(initiation -> Mono.defer(() -> {
|
||||
String reference = UUID.randomUUID().toString();
|
||||
// 202 Accepted, empty body — the reference is all we get back
|
||||
|
||||
167
src/main/java/com/test/payment/service/PaymentLimitService.java
Normal file
167
src/main/java/com/test/payment/service/PaymentLimitService.java
Normal file
@@ -0,0 +1,167 @@
|
||||
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.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Enforces the ceilings configured in PROVIDER_LIMITS before a payment is
|
||||
* persisted or dispatched. Cumulative periods (DAILY, MONTHLY) are scoped to the
|
||||
* paying MSISDN on that provider, which is how mobile-money operators express them.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PaymentLimitService {
|
||||
|
||||
private final ProviderLimitRepository limitRepository;
|
||||
private final PaymentInitiationRepository initiationRepository;
|
||||
|
||||
/**
|
||||
* Completes empty when the request is within every active limit; signals
|
||||
* PaymentLimitExceededException on the first breach.
|
||||
*/
|
||||
public Mono<Void> enforce(String provider, PaymentRequest request) {
|
||||
BigDecimal amount = BigDecimal.valueOf(request.getAmount());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
return limitRepository.findByProviderAndActiveTrue(provider)
|
||||
.concatMap(limit -> check(limit, provider, request.getPhoneNumber(), amount, now))
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<Void> check(ProviderLimit limit, String provider, String phoneNumber,
|
||||
BigDecimal amount, LocalDateTime now) {
|
||||
LimitPeriod period = parsePeriod(limit);
|
||||
if (period == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
if (!period.isCumulative()) {
|
||||
return amount.compareTo(limit.getMaxAmount()) > 0
|
||||
? Mono.error(breach(limit, provider, amount, BigDecimal.ZERO))
|
||||
: Mono.empty();
|
||||
}
|
||||
|
||||
LocalDateTime since = period.windowStart(now);
|
||||
Mono<BigDecimal> alreadySpentInWindow = scopeOf(limit) == LimitScope.MERCHANT
|
||||
? initiationRepository.sumAmountInWindowForProvider(provider, since)
|
||||
: initiationRepository.sumAmountInWindow(provider, phoneNumber, since);
|
||||
|
||||
return alreadySpentInWindow
|
||||
.defaultIfEmpty(BigDecimal.ZERO)
|
||||
.flatMap(alreadySpent -> alreadySpent.add(amount).compareTo(limit.getMaxAmount()) > 0
|
||||
? Mono.error(breach(limit, provider, amount, alreadySpent))
|
||||
: Mono.empty());
|
||||
}
|
||||
|
||||
private PaymentLimitExceededException breach(ProviderLimit limit, String 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);
|
||||
}
|
||||
|
||||
public Flux<ProviderLimit> list(String 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.
|
||||
*/
|
||||
public Mono<ProviderLimit> upsert(ProviderLimitDto dto) {
|
||||
String provider = dto.getProvider().toUpperCase();
|
||||
String period = dto.getPeriod().toUpperCase();
|
||||
String scope = dto.getScope() == null ? LimitScope.PER_PAYER.name() : dto.getScope().toUpperCase();
|
||||
validateNames(provider, period, scope);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return limitRepository.findByProviderAndPeriodAndScope(provider, period, scope)
|
||||
.switchIfEmpty(Mono.fromSupplier(() -> ProviderLimit.builder()
|
||||
.provider(provider)
|
||||
.period(period)
|
||||
.scope(scope)
|
||||
.createdAt(now)
|
||||
.build()))
|
||||
.flatMap(limit -> {
|
||||
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);
|
||||
return limitRepository.save(limit);
|
||||
})
|
||||
.doOnNext(limit -> log.info("Provider limit set: {} {} = {}",
|
||||
limit.getProvider(), limit.getPeriod(), limit.getMaxAmount()));
|
||||
}
|
||||
|
||||
private void validateNames(String provider, String period, String scope) {
|
||||
try {
|
||||
PaymentProviderType.valueOf(provider);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
server:
|
||||
port: 8091
|
||||
|
||||
resilience4j:
|
||||
ratelimiter:
|
||||
instances:
|
||||
|
||||
Reference in New Issue
Block a user