diff --git a/CLAUDE.md b/CLAUDE.md index ee45d9c..3a0f0a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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). diff --git a/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java b/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java index 2e124c6..d0daca4 100644 --- a/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java +++ b/src/main/java/com/test/payment/configurations/DatabaseSchemaInitializer.java @@ -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(); } diff --git a/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java b/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java index 2e1d0cb..674ff42 100644 --- a/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/test/payment/controller/GlobalExceptionHandler.java @@ -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) { diff --git a/src/main/java/com/test/payment/controller/PaymentsController.java b/src/main/java/com/test/payment/controller/PaymentsController.java index b783bc5..a65124c 100644 --- a/src/main/java/com/test/payment/controller/PaymentsController.java +++ b/src/main/java/com/test/payment/controller/PaymentsController.java @@ -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 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 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 setLimit(@Valid @RequestBody ProviderLimitDto request) { + return limits.upsert(request); + } } diff --git a/src/main/java/com/test/payment/dto/ProviderLimitDto.java b/src/main/java/com/test/payment/dto/ProviderLimitDto.java new file mode 100644 index 0000000..f8d7eae --- /dev/null +++ b/src/main/java/com/test/payment/dto/ProviderLimitDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/test/payment/exceptions/PaymentLimitExceededException.java b/src/main/java/com/test/payment/exceptions/PaymentLimitExceededException.java new file mode 100644 index 0000000..6daed9a --- /dev/null +++ b/src/main/java/com/test/payment/exceptions/PaymentLimitExceededException.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/test/payment/models/DatabaseSchema.java b/src/main/java/com/test/payment/models/DatabaseSchema.java index 6971684..b979b38 100644 --- a/src/main/java/com/test/payment/models/DatabaseSchema.java +++ b/src/main/java/com/test/payment/models/DatabaseSchema.java @@ -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 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); + } } diff --git a/src/main/java/com/test/payment/models/LimitPeriod.java b/src/main/java/com/test/payment/models/LimitPeriod.java new file mode 100644 index 0000000..23cd2b9 --- /dev/null +++ b/src/main/java/com/test/payment/models/LimitPeriod.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/test/payment/models/LimitScope.java b/src/main/java/com/test/payment/models/LimitScope.java new file mode 100644 index 0000000..d4ee5a8 --- /dev/null +++ b/src/main/java/com/test/payment/models/LimitScope.java @@ -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 +} diff --git a/src/main/java/com/test/payment/models/ProviderLimit.java b/src/main/java/com/test/payment/models/ProviderLimit.java new file mode 100644 index 0000000..f6924c8 --- /dev/null +++ b/src/main/java/com/test/payment/models/ProviderLimit.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java index b15206e..1b63565 100644 --- a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java +++ b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java @@ -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 { Flux 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 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 sumAmountInWindowForProvider(String provider, LocalDateTime since); } diff --git a/src/main/java/com/test/payment/repository/ProviderLimitRepository.java b/src/main/java/com/test/payment/repository/ProviderLimitRepository.java new file mode 100644 index 0000000..01e9a17 --- /dev/null +++ b/src/main/java/com/test/payment/repository/ProviderLimitRepository.java @@ -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 { + + Flux findByProviderAndActiveTrue(String provider); + + Flux findByProviderOrderByPeriod(String provider); + + Flux findAllByOrderByProviderAscPeriodAsc(); + + Mono findByProviderAndPeriodAndScope(String provider, String period, String scope); +} \ No newline at end of file diff --git a/src/main/java/com/test/payment/service/AirtelService.java b/src/main/java/com/test/payment/service/AirtelService.java index 5475671..0f7053f 100644 --- a/src/main/java/com/test/payment/service/AirtelService.java +++ b/src/main/java/com/test/payment/service/AirtelService.java @@ -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 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)) diff --git a/src/main/java/com/test/payment/service/MpesaService.java b/src/main/java/com/test/payment/service/MpesaService.java index f9497b6..d334d7d 100644 --- a/src/main/java/com/test/payment/service/MpesaService.java +++ b/src/main/java/com/test/payment/service/MpesaService.java @@ -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 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))); diff --git a/src/main/java/com/test/payment/service/MtnService.java b/src/main/java/com/test/payment/service/MtnService.java index aea8025..75a1c1c 100644 --- a/src/main/java/com/test/payment/service/MtnService.java +++ b/src/main/java/com/test/payment/service/MtnService.java @@ -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 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 diff --git a/src/main/java/com/test/payment/service/PaymentLimitService.java b/src/main/java/com/test/payment/service/PaymentLimitService.java new file mode 100644 index 0000000..302da11 --- /dev/null +++ b/src/main/java/com/test/payment/service/PaymentLimitService.java @@ -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 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 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 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 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 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; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4c95555..42bc6d9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,6 @@ +server: + port: 8091 + resilience4j: ratelimiter: instances: