This commit is contained in:
spiro-alvin-nyasimi
2026-07-27 10:27:58 +03:00
parent 3ab155a8b8
commit c6a6755b08
17 changed files with 437 additions and 3 deletions

View File

@@ -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).

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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);
}
}

View 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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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
}

View 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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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))

View File

@@ -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)));

View File

@@ -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

View 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;
}
}
}

View File

@@ -1,3 +1,6 @@
server:
port: 8091
resilience4j:
ratelimiter:
instances: