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

@@ -2,12 +2,12 @@ package com.test.payment;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableR2dbcRepositories(basePackages = "com.test.payment.repository")
@EnableJpaRepositories(basePackages = "com.test.payment.repository")
public class PaymentApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,43 @@
package com.test.payment.configurations;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* The thread pool the provider-call audit trail is written on. Deliberately separate
* from the payment path: audit writes are best-effort bookkeeping and must never add
* latency to, or fail, a collection.
*/
@Configuration
@Slf4j
public class AuditExecutorConfig {
/**
* Bounded queue with a discard policy — if the operator traffic ever outruns the
* database, we drop audit rows (and say so) rather than pile up heap or block a
* caller. Losing an audit row is survivable; stalling a payment is not.
*/
@Bean(destroyMethod = "shutdown")
public ExecutorService auditExecutor() {
AtomicInteger counter = new AtomicInteger();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> {
Thread thread = new Thread(runnable, "provider-audit-" + counter.incrementAndGet());
thread.setDaemon(true);
return thread;
},
(runnable, pool) -> log.warn("Provider audit queue full — dropping one audit write"));
executor.allowCoreThreadTimeOut(true);
return executor;
}
}

View File

@@ -0,0 +1,117 @@
package com.test.payment.configurations;
import com.test.payment.models.LimitPeriod;
import com.test.payment.models.Operator;
import com.test.payment.models.LimitScope;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.ProviderLimit;
import com.test.payment.models.Status;
import com.test.payment.repository.ProviderLimitRepository;
import com.test.payment.repository.StatusRepository;
import com.test.payment.service.ProviderMarkets;
import com.test.payment.service.StatusCatalog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Seeds the reference data Hibernate cannot derive from the entities: the payment
* {@link Status} rows and the default provider ceilings. Every insert is guarded by
* an existence check, so edits made at runtime survive a restart.
*
* <p>Replaces the old DatabaseSchema/DatabaseSchemaInitializer pair — the schema
* itself is now owned by Hibernate (spring.jpa.hibernate.ddl-auto).
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DataSeeder implements ApplicationRunner {
private final StatusRepository statusRepository;
private final ProviderLimitRepository limitRepository;
private final StatusCatalog statusCatalog;
private final ProviderMarkets markets;
private final Environment environment;
@Override
@Transactional
public void run(ApplicationArguments args) {
seedStatuses();
seedProviderLimits();
}
private void seedStatuses() {
int inserted = 0;
for (StatusCatalog.StatusDefinition status : statusCatalog.defaults()) {
if (statusRepository.findByName(status.name()).isEmpty()) {
statusRepository.save(Status.builder()
.Name(status.name())
.Description(status.description())
.Registered(Instant.now())
.build());
inserted++;
}
}
statusCatalog.invalidate();
log.info("Statuses seeded ({} inserted, {} already present)",
inserted, statusCatalog.defaults().size() - inserted);
}
/**
* Default ceilings per operator: per-transaction, daily per payer, daily
* merchant-wide. Seeded only for the markets actually configured — AIRTEL_KE
* gets rows, AIRTEL_UG only does once someone points airtel.country at Uganda.
*/
private static final Map<Operator, String[]> DEFAULT_CEILINGS = new EnumMap<>(Map.of(
Operator.MPESA, new String[]{"250000.00", "500000.00", "5000000.00"},
Operator.AIRTEL, new String[]{"150000.00", "300000.00", "3000000.00"},
Operator.MTN, new String[]{"5000.00", "10000.00", "100000.00"}));
private void seedProviderLimits() {
int inserted = 0;
for (PaymentProviderType provider : configuredProviders()) {
String[] ceilings = DEFAULT_CEILINGS.get(provider.operator());
// the currency the service actually sends, so ceilings and charges agree
String currency = environment.getProperty(
provider.operator().name().toLowerCase() + ".currency", provider.currency());
inserted += seedLimit(provider, LimitPeriod.PER_TRANSACTION, LimitScope.PER_PAYER, ceilings[0], currency);
inserted += seedLimit(provider, LimitPeriod.DAILY, LimitScope.PER_PAYER, ceilings[1], currency);
inserted += seedLimit(provider, LimitPeriod.DAILY, LimitScope.MERCHANT, ceilings[2], currency);
}
log.info("Default provider limits seeded ({} inserted)", inserted);
}
/** The market-qualified providers this deployment is wired for. */
private List<PaymentProviderType> configuredProviders() {
return Arrays.stream(Operator.values()).map(markets::resolve).toList();
}
private int seedLimit(PaymentProviderType provider, LimitPeriod period, LimitScope scope,
String maxAmount, String currency) {
if (limitRepository.findByProviderAndPeriodAndScope(provider, period.name(), scope.name()).isPresent()) {
return 0;
}
limitRepository.save(ProviderLimit.builder()
.Provider(provider)
.Period(period.name())
.Scope(scope.name())
.MaxAmount(new BigDecimal(maxAmount))
.Currency(currency)
.Active(Boolean.TRUE)
.CreatedAt(LocalDateTime.now())
.build());
return 1;
}
}

View File

@@ -1,29 +0,0 @@
package com.test.payment.configurations;
import com.test.payment.models.DatabaseSchema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.r2dbc.core.DatabaseClient;
import reactor.core.publisher.Flux;
/**
* Creates the schema from DatabaseSchema.STATEMENTS at startup
* (replaces spring.sql.init + schema.sql).
*/
@Configuration
@Slf4j
public class DatabaseSchemaInitializer {
@Bean
public org.springframework.beans.factory.InitializingBean schemaInitializer(DatabaseClient databaseClient) {
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.ProviderLimitDto;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.ProviderLimit;
import com.test.payment.models.Transaction;
import com.test.payment.service.PaymentLifecycleService;
@@ -13,9 +14,13 @@ 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 org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Arrays;
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
@@ -25,19 +30,19 @@ public class PaymentsController {
private final PaymentLimitService limits;
/**
* All consolidated transactions across providers; optional ?provider=MPESA|AIRTEL|MTN filter.
* All consolidated transactions across providers; optional ?provider=MPESA_KE|AIRTEL_KE|MTN_UG filter.
*/
@GetMapping("/transactions")
public Flux<Transaction> transactions(@RequestParam(required = false) String provider) {
return lifecycle.listTransactions(provider == null ? null : provider.toUpperCase());
return lifecycle.listTransactions(parseProvider(provider));
}
/**
* Configured payment ceilings; optional ?provider=MPESA|AIRTEL|MTN filter.
* Configured payment ceilings; optional ?provider=MPESA_KE|AIRTEL_KE|MTN_UG filter.
*/
@GetMapping("/limits")
public Flux<ProviderLimit> limits(@RequestParam(required = false) String provider) {
return limits.list(provider == null ? null : provider.toUpperCase());
return limits.list(parseProvider(provider));
}
/**
@@ -47,4 +52,17 @@ public class PaymentsController {
public Mono<ProviderLimit> setLimit(@Valid @RequestBody ProviderLimitDto request) {
return limits.upsert(request);
}
/** Null (meaning "every provider") stays null; anything else must name a real provider. */
private PaymentProviderType parseProvider(String provider) {
if (provider == null) {
return null;
}
try {
return PaymentProviderType.valueOf(provider.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"provider must be one of " + Arrays.toString(PaymentProviderType.values()));
}
}
}

View File

@@ -1,5 +1,6 @@
package com.test.payment.dto;
import com.test.payment.models.PaymentProviderType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -12,7 +13,7 @@ import lombok.NoArgsConstructor;
public class PaymentResultDto {
private Long initiationId;
private String provider;
private PaymentProviderType provider;
private String status;
private String providerReference;
private String secondaryReference;

View File

@@ -1,5 +1,6 @@
package com.test.payment.dto;
import com.test.payment.models.PaymentProviderType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -15,7 +16,7 @@ import java.time.LocalDateTime;
public class TransactionStatusDto {
private Long initiationId;
private String provider;
private PaymentProviderType provider;
private String providerReference;
private String secondaryReference;
private String status;

View File

@@ -1,7 +1,6 @@
package com.test.payment.jobs;
import com.test.payment.models.TransactionStatus;
import com.test.payment.repository.PaymentInitiationRepository;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.service.PaymentLifecycleService;
import com.test.payment.service.PaymentProviderService;
import lombok.extern.slf4j.Slf4j;
@@ -28,17 +27,14 @@ import java.util.stream.Collectors;
@Slf4j
public class PaymentReconciliationJob {
private final PaymentInitiationRepository initiationRepository;
private final PaymentLifecycleService lifecycle;
private final Environment environment;
private final Map<String, PaymentProviderService> servicesByProvider;
private final Map<PaymentProviderType, PaymentProviderService> servicesByProvider;
private final AtomicBoolean running = new AtomicBoolean(false);
public PaymentReconciliationJob(PaymentInitiationRepository initiationRepository,
PaymentLifecycleService lifecycle,
public PaymentReconciliationJob(PaymentLifecycleService lifecycle,
Environment environment,
List<PaymentProviderService> providerServices) {
this.initiationRepository = initiationRepository;
this.lifecycle = lifecycle;
this.environment = environment;
this.servicesByProvider = providerServices.stream()
@@ -55,7 +51,7 @@ public class PaymentReconciliationJob {
environment.getProperty("payments.reconciliation.pending-age", "5m"));
LocalDateTime cutoff = LocalDateTime.now().minus(pendingAge);
initiationRepository.findByStatusAndCreatedAtBefore(TransactionStatus.PENDING.name(), cutoff)
lifecycle.findPendingOlderThan(cutoff)
.concatMap(initiation -> {
PaymentProviderService service = servicesByProvider.get(initiation.getProvider());
if (service == null) {

View File

@@ -0,0 +1,81 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* The Airtel Money result callback that resolved an initiation. Deduplicated: one row per
* initiation, so a repeated callback is ignored rather than reapplied. The complete,
* duplicate-inclusive record lives in airtel_callback_responses instead.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class AirtelPaymentCallback {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this resolves. Unique: this is what makes the dedup work. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
@Column(columnDefinition = "TEXT")
private String RawPayload;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,73 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Airtel Money's answer to an initiation. providerReference is the generated ATL transaction id.
*
* <p>One row per initiation. Independent of the other operators' response tables —
* the lifecycle reads it through PaymentLifecycleStore's StoredResponse view.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class AirtelPaymentResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this answers. Unique: one response per initiation. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Lookup key for callbacks and status checks. */
@Column(unique = true, length = 100)
private String ProviderReference;
@Column(length = 100)
private String SecondaryReference;
@Column(length = 30)
private String ResponseCode;
@Column(length = 255)
private String ResponseDescription;
@Column(length = 255)
private String CustomerMessage;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,59 @@
package com.test.payment.models;
import java.util.Arrays;
import java.util.Optional;
/**
* The markets this service can transact in. The ISO-4217 currency and the E.164
* dialing prefix travel with the country so a provider never has to guess either —
* see {@link PaymentProviderType#currencyFor(Country)}.
*/
public enum Country {
KE("Kenya", "KES", "254"),
TZ("Tanzania", "TZS", "255"),
UG("Uganda", "UGX", "256"),
RW("Rwanda", "RWF", "250"),
GH("Ghana", "GHS", "233"),
ZM("Zambia", "ZMW", "260"),
CM("Cameroon", "XAF", "237"),
CI("Côte d'Ivoire", "XOF", "225");
private final String displayName;
private final String currency;
private final String dialingCode;
Country(String displayName, String currency, String dialingCode) {
this.displayName = displayName;
this.currency = currency;
this.dialingCode = dialingCode;
}
public String displayName() {
return displayName;
}
/** ISO-4217 code of the country's own currency. */
public String currency() {
return currency;
}
/** E.164 country calling code, without the leading '+'. */
public String dialingCode() {
return dialingCode;
}
/** True when an MSISDN in international format belongs to this country. */
public boolean owns(String msisdn) {
return msisdn != null && msisdn.startsWith(dialingCode);
}
/** Case-insensitive lookup by ISO-3166 alpha-2 code; empty when unknown. */
public static Optional<Country> of(String isoCode) {
return isoCode == null
? Optional.empty()
: Arrays.stream(values())
.filter(country -> country.name().equalsIgnoreCase(isoCode.trim()))
.findFirst();
}
}

View File

@@ -1,147 +0,0 @@
package com.test.payment.models;
import java.util.List;
/**
* The database schema as code — executed statement-by-statement at startup by
* DatabaseSchemaInitializer (replaces the old classpath schema.sql).
*/
public final class DatabaseSchema {
private DatabaseSchema() {
}
public static final List<String> STATEMENTS = List.of(
"""
CREATE TABLE IF NOT EXISTS provider_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(20) NOT NULL,
access_token VARCHAR(512) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS payment_initiations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(20) NOT NULL,
phone_number VARCHAR(15) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
account_reference VARCHAR(50),
transaction_desc VARCHAR(100),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_initiations_status_created
ON payment_initiations (status, created_at)
""",
// One response per initiation (UNIQUE on initiation_id enforces the 1:1 link).
// provider_reference is the ID used for callbacks/status checks
// (M-Pesa CheckoutRequestID, Airtel transaction id, MTN X-Reference-Id).
"""
CREATE TABLE IF NOT EXISTS payment_responses (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
initiation_id BIGINT NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL,
provider_reference VARCHAR(100) UNIQUE,
secondary_reference VARCHAR(100),
response_code VARCHAR(30),
response_description VARCHAR(255),
customer_message VARCHAR(255),
created_at TIMESTAMP NOT NULL,
CONSTRAINT fk_response_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id)
)
""",
// One callback per initiation (UNIQUE on initiation_id enforces the 1:1 link)
"""
CREATE TABLE IF NOT EXISTS payment_callbacks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
initiation_id BIGINT NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL,
provider_reference VARCHAR(100),
result_code VARCHAR(30),
result_desc VARCHAR(255),
receipt_number VARCHAR(50),
amount DECIMAL(10,2),
phone_number VARCHAR(15),
transaction_date VARCHAR(20),
raw_payload CLOB,
created_at TIMESTAMP NOT NULL,
CONSTRAINT fk_callback_initiation FOREIGN KEY (initiation_id) REFERENCES payment_initiations (id)
)
""",
// Consolidated transaction record, written when an initiation reaches a
// terminal state (SUCCESS/FAILED). UNIQUE initiation_id: one per initiation.
"""
CREATE TABLE IF NOT EXISTS transactions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
initiation_id BIGINT NOT NULL UNIQUE,
provider VARCHAR(20) NOT NULL,
provider_reference VARCHAR(100),
secondary_reference VARCHAR(100),
phone_number VARCHAR(15),
amount DECIMAL(10,2),
account_reference VARCHAR(50),
status VARCHAR(20) NOT NULL,
result_code VARCHAR(30),
result_desc VARCHAR(255),
receipt_number VARCHAR(50),
transaction_date VARCHAR(20),
resolved_by VARCHAR(20),
created_at TIMESTAMP NOT NULL,
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,81 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* The M-Pesa result callback that resolved an initiation. Deduplicated: one row per
* initiation, so a repeated callback is ignored rather than reapplied. The complete,
* duplicate-inclusive record lives in mpesa_callback_responses instead.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class MpesaPaymentCallback {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this resolves. Unique: this is what makes the dedup work. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
@Column(columnDefinition = "TEXT")
private String RawPayload;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,73 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* M-Pesa's answer to an initiation. providerReference is the CheckoutRequestID; secondaryReference the MerchantRequestID.
*
* <p>One row per initiation. Independent of the other operators' response tables —
* the lifecycle reads it through PaymentLifecycleStore's StoredResponse view.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class MpesaPaymentResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this answers. Unique: one response per initiation. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Lookup key for callbacks and status checks. */
@Column(unique = true, length = 100)
private String ProviderReference;
@Column(length = 100)
private String SecondaryReference;
@Column(length = 30)
private String ResponseCode;
@Column(length = 255)
private String ResponseDescription;
@Column(length = 255)
private String CustomerMessage;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,81 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* The MTN MoMo result callback that resolved an initiation. Deduplicated: one row per
* initiation, so a repeated callback is ignored rather than reapplied. The complete,
* duplicate-inclusive record lives in mtn_callback_responses instead.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class MtnPaymentCallback {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this resolves. Unique: this is what makes the dedup work. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
@Column(columnDefinition = "TEXT")
private String RawPayload;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,73 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* MTN MoMo's answer to an initiation. providerReference is the generated X-Reference-Id UUID.
*
* <p>One row per initiation. Independent of the other operators' response tables —
* the lifecycle reads it through PaymentLifecycleStore's StoredResponse view.
*/
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "Initiation")
public class MtnPaymentResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** The attempt this answers. Unique: one response per initiation. */
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Lookup key for callbacks and status checks. */
@Column(unique = true, length = 100)
private String ProviderReference;
@Column(length = 100)
private String SecondaryReference;
@Column(length = 30)
private String ResponseCode;
@Column(length = 255)
private String ResponseDescription;
@Column(length = 255)
private String CustomerMessage;
@Column(nullable = false)
private LocalDateTime CreatedAt;
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -0,0 +1,43 @@
package com.test.payment.models;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
/**
* A mobile-money operator — the integration itself (one client, one token service,
* one set of Resilience4j instances), independent of the market it is used in.
* The market-qualified identity a payment is actually recorded against is
* {@link PaymentProviderType} (AIRTEL_KE, AIRTEL_UG, ...).
*/
public enum Operator {
MPESA(EnumSet.of(Country.KE, Country.TZ)),
AIRTEL(EnumSet.of(Country.KE, Country.UG, Country.TZ, Country.RW, Country.ZM)),
MTN(EnumSet.of(Country.UG, Country.GH, Country.CM, Country.CI, Country.RW, Country.ZM));
private final Set<Country> countries;
Operator(Set<Country> countries) {
this.countries = Collections.unmodifiableSet(countries);
}
/** Every market this operator serves. */
public Set<Country> countries() {
return countries;
}
public boolean supports(Country country) {
return country != null && countries.contains(country);
}
/**
* @throws IllegalArgumentException when this operator does not trade in the market
*/
public void requireSupported(Country country) {
if (!supports(country)) {
throw new IllegalArgumentException("%s does not operate in %s — supported markets: %s".formatted(
name(), country == null ? "an unknown country" : country.displayName(), countries));
}
}
}

View File

@@ -1,33 +0,0 @@
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;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("PAYMENT_CALLBACKS")
public class PaymentCallback {
@Id
private Long id;
private Long initiationId;
private String provider;
private String providerReference;
private String resultCode;
private String resultDesc;
private String receiptNumber;
private BigDecimal amount;
private String phoneNumber;
private String transactionDate;
private String rawPayload;
private LocalDateTime createdAt;
}

View File

@@ -1,30 +1,70 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("PAYMENT_INITIATIONS")
@ToString
public class PaymentInitiation {
@Id
private Long id;
private String provider;
private String phoneNumber;
private BigDecimal amount;
private String accountReference;
private String transactionDesc;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(nullable = false, length = 15)
private String PhoneNumber;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 5000)
private String AccountReference;
@Column(length = 10000)
private String TransactionDesc;
// Left at @ManyToOne's default EAGER: every read of an initiation reports its
// status, and the reactive layer touches it after the transaction has closed,
// where a lazy proxy would already be detached.
@ManyToOne(targetEntity = Status.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Status", nullable = false)
private Status Status;
@Column(nullable = false)
private LocalDateTime CreatedAt;
private LocalDateTime UpdatedAt;
/** The status name ("Pending", "Success", ...), or null when unset. */
public String statusName() {
return Status == null ? null : Status.getName();
}
}

View File

@@ -1,7 +1,84 @@
package com.test.payment.models;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* A provider as payments are actually recorded against it: an {@link Operator} in a
* specific market. Airtel in Kenya and Airtel in Uganda are different float pools,
* different currencies and different ceilings, so they are different providers here
* — AIRTEL_KE and AIRTEL_UG — even though they share one client and one token.
*
* <p>The enum name is what lands in the {@code provider} column and in every
* provider-scoped lookup (limits, reconciliation dispatch, callback matching).
*/
public enum PaymentProviderType {
MPESA,
AIRTEL,
MTN
MPESA_KE(Operator.MPESA, Country.KE),
MPESA_TZ(Operator.MPESA, Country.TZ),
AIRTEL_KE(Operator.AIRTEL, Country.KE),
AIRTEL_UG(Operator.AIRTEL, Country.UG),
AIRTEL_TZ(Operator.AIRTEL, Country.TZ),
AIRTEL_RW(Operator.AIRTEL, Country.RW),
AIRTEL_ZM(Operator.AIRTEL, Country.ZM),
MTN_UG(Operator.MTN, Country.UG),
MTN_GH(Operator.MTN, Country.GH),
MTN_CM(Operator.MTN, Country.CM),
MTN_CI(Operator.MTN, Country.CI),
MTN_RW(Operator.MTN, Country.RW),
MTN_ZM(Operator.MTN, Country.ZM);
private final Operator operator;
private final Country country;
PaymentProviderType(Operator operator, Country country) {
this.operator = operator;
this.country = country;
}
public Operator operator() {
return operator;
}
public Country country() {
return country;
}
/**
* The currency this provider settles in. Sandbox environments sometimes disagree
* (MTN's only prices in EUR), which is why the provider services still let
* {@code <operator>.currency} override it.
*/
public String currency() {
return country.currency();
}
/** Every market this operator is wired up for. */
public static List<PaymentProviderType> forOperator(Operator operator) {
return Arrays.stream(values()).filter(type -> type.operator == operator).toList();
}
/** Every operator able to collect a payment in this market. */
public static List<PaymentProviderType> forCountry(Country country) {
return Arrays.stream(values()).filter(type -> type.country == country).toList();
}
public static Optional<PaymentProviderType> of(Operator operator, Country country) {
return Arrays.stream(values())
.filter(type -> type.operator == operator && type.country == country)
.findFirst();
}
/**
* @throws IllegalStateException when the operator is not wired up for that market —
* a misconfiguration, caught at startup rather than mid-payment
*/
public static PaymentProviderType require(Operator operator, Country country) {
operator.requireSupported(country);
return of(operator, country).orElseThrow(() -> new IllegalStateException(
"No provider constant for %s in %s".formatted(operator, country)));
}
}

View File

@@ -1,35 +0,0 @@
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.time.LocalDateTime;
/**
* The provider's answer to an initiation. providerReference is the ID later used
* for callbacks and status checks (M-Pesa CheckoutRequestID, Airtel transaction id,
* MTN X-Reference-Id); secondaryReference is any additional provider ID
* (M-Pesa MerchantRequestID).
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("PAYMENT_RESPONSES")
public class PaymentResponse {
@Id
private Long id;
private Long initiationId;
private String provider;
private String providerReference;
private String secondaryReference;
private String responseCode;
private String responseDescription;
private String customerMessage;
private LocalDateTime createdAt;
}

View File

@@ -1,34 +1,66 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Setter;
import lombok.ToString;
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.
* A configurable payment ceiling for one provider over one {@link LimitPeriod},
* for one {@link LimitScope}. Seeded with defaults at startup and editable at
* runtime via /api/payments/limits.
*/
@Data
@Entity
@Table(name = "provider_limits",
uniqueConstraints = @UniqueConstraint(name = "uq_provider_limit",
columnNames = {"provider", "period", "scope"}))
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("PROVIDER_LIMITS")
@ToString
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;
}
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(nullable = false, length = 20)
private String Period;
@Column(nullable = false, length = 20)
private String Scope;
@Column(nullable = false, precision = 14, scale = 2)
private BigDecimal MaxAmount;
@Column(length = 5)
private String Currency;
@Column(nullable = false)
private Boolean Active;
@Column(nullable = false)
private LocalDateTime CreatedAt;
private LocalDateTime UpdatedAt;
}

View File

@@ -1,25 +1,49 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
@Data
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("PROVIDER_TOKENS")
@ToString
public class ProviderToken {
@Id
private Long id;
private String provider;
private String accessToken;
private LocalDateTime expiresAt;
private LocalDateTime createdAt;
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Operator Provider;
@Column(nullable = false, columnDefinition = "TEXT")
private String AccessToken;
@Column(nullable = false)
private LocalDateTime ExpiresAt;
@Column(nullable = false)
private LocalDateTime CreatedAt;
}

View File

@@ -0,0 +1,49 @@
package com.test.payment.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* The lifecycle state of a payment, as a lookup row rather than an enum, so states
* can be added or described without a redeploy. Referenced many-to-one by
* {@link PaymentInitiation} and {@link Transaction}; seeded by StatusSeeder.
*/
@Entity
@Table(name = "statuses")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Status {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "status_sequencer")
@SequenceGenerator(name = "status_sequencer", sequenceName = "status_sequencer",
initialValue = 10001, allocationSize = 1)
private Long Id;
@Column(nullable = false, unique = true, updatable = false, length = 20)
private String Name;
@Column(nullable = false, length = 2000)
private String Description;
@Column(nullable = false, updatable = false)
private Instant Registered;
}

View File

@@ -1,42 +1,144 @@
package com.test.payment.models;
import com.test.payment.models.audit.AirtelRequest;
import com.test.payment.models.audit.MpesaRequest;
import com.test.payment.models.audit.MtnRequest;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* Consolidated record written when a payment attempt reaches a terminal state
* (SUCCESS or FAILED) — one row per initiation, whatever path resolved it
* (callback, status query, immediate rejection, or reconciliation timeout).
*/
@Data
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table("TRANSACTIONS")
@ToString(exclude = {"Initiation", "MpesaRequest", "AirtelRequest", "MtnRequest",
"MpesaCallback", "AirtelCallback", "MtnCallback"})
public class Transaction {
@Id
private Long id;
private Long initiationId;
private String provider;
private String providerReference;
private String secondaryReference;
private String phoneNumber;
private BigDecimal amount;
private String accountReference;
private String status;
private String resultCode;
private String resultDesc;
private String receiptNumber;
private String transactionDate;
private String resolvedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/**
* The attempt this consolidates. Unique: one transaction per initiation,
* whichever path (callback, query, rejection, reconciliation) resolved it.
*/
@ManyToOne(targetEntity = PaymentInitiation.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Initiation", nullable = false, unique = true)
private PaymentInitiation Initiation;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/**
* The outbound call that started this payment, and through it everything the
* operator returned. Exactly one of the three is ever set — whichever operator
* owns the row — because the request tables are per operator and share no
* supertype.
*/
@ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "MpesaRequest")
private MpesaRequest MpesaRequest;
@ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "AirtelRequest")
private AirtelRequest AirtelRequest;
@ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "MtnRequest")
private MtnRequest MtnRequest;
/** The callback that resolved the payment, again one per operator. */
@ManyToOne(targetEntity = MpesaPaymentCallback.class, fetch = FetchType.LAZY)
@JoinColumn(name = "MpesaCallback")
private MpesaPaymentCallback MpesaCallback;
@ManyToOne(targetEntity = AirtelPaymentCallback.class, fetch = FetchType.LAZY)
@JoinColumn(name = "AirtelCallback")
private AirtelPaymentCallback AirtelCallback;
@ManyToOne(targetEntity = MtnPaymentCallback.class, fetch = FetchType.LAZY)
@JoinColumn(name = "MtnCallback")
private MtnPaymentCallback MtnCallback;
@Column(length = 100)
private String ProviderReference;
@Column(length = 100)
private String SecondaryReference;
@Column(length = 15)
private String PhoneNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 50)
private String AccountReference;
@ManyToOne(targetEntity = Status.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Status", nullable = false)
private Status Status;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(length = 20)
private String TransactionDate;
/** What resolved the payment: CALLBACK, QUERY, REJECTION, ERROR, RECONCILIATION. Null while open. */
@Column(length = 20)
private String ResolvedBy;
@Column(nullable = false)
private LocalDateTime CreatedAt;
private LocalDateTime UpdatedAt;
/** True once the outbound request has been recorded against this transaction. */
public boolean hasRequest() {
return MpesaRequest != null || AirtelRequest != null || MtnRequest != null;
}
/** True once a result callback has been tied to this transaction. */
public boolean hasCallback() {
return MpesaCallback != null || AirtelCallback != null || MtnCallback != null;
}
/** The status name ("Pending", "Paid", ...), or null when unset. */
public String statusName() {
return Status == null ? null : Status.getName();
}
/** Convenience for logging and DTOs that only need the initiation's id. */
public Long initiationId() {
return Initiation == null ? null : Initiation.getId();
}
}

View File

@@ -1,7 +0,0 @@
package com.test.payment.models;
public enum TransactionStatus {
PENDING,
SUCCESS,
FAILED
}

View File

@@ -0,0 +1,78 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.Instant;
/**
* An asynchronous result callback from Airtel Money, stored whole alongside the fields
* worth querying on, and tied back to the call that caused it. Unlike
* PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail
* that silently drops repeats is not one.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class AirtelCallbackResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Null when the callback could not be matched to an outbound request. */
@ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request")
private AirtelRequest Request;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
/** The callback exactly as it arrived. */
@Column(columnDefinition = "TEXT")
private String RawPayload;
/** False when no matching request could be found — an orphan worth alerting on. */
@Column(nullable = false)
private boolean Matched;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,68 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* One outbound call to Airtel Money, recorded verbatim: what we sent, where, and on whose
* behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path
* waits for this row.
*/
@Entity
@Getter
@Setter
@ToString
public class AirtelRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Market-qualified provider, e.g. AIRTEL_KE. */
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Which call this was: USSD_PUSH, STATUS_QUERY, TOKEN. */
@Column(nullable = false, length = 40)
private String Operation;
/**
* The payment attempt this call belongs to; null for calls with no payment
* (token fetches). A string, not the numeric id, so it can carry a NanoID.
*/
@Column(length = 36)
private String InitiationId;
/**
* The provider's own reference once known, so a later callback can be tied back
* to the exact call that produced it.
*/
@Column(length = 100)
private String ProviderReference;
@Column(length = 10)
private String HttpMethod;
@Column(length = 512)
private String Url;
/** Outbound body as sent, JSON. Credentials are redacted before this is stored. */
@Column(columnDefinition = "TEXT")
private String RequestBody;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,52 @@
package com.test.payment.models.audit;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* What Airtel Money answered a AirtelRequest with — including the failures, which is
* usually what an audit is actually needed for.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class AirtelResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@ManyToOne(targetEntity = AirtelRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request", nullable = false)
private AirtelRequest Request;
/** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */
private Integer HttpStatus;
/** Response body verbatim, JSON. */
@Column(columnDefinition = "TEXT")
private String ResponseBody;
/** Exception summary when the call failed outright. */
@Column(length = 512)
private String Error;
/** Round-trip time, for spotting Airtel Money degrading before it starts erroring. */
private Long DurationMs;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,78 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.Instant;
/**
* An asynchronous result callback from M-Pesa, stored whole alongside the fields
* worth querying on, and tied back to the call that caused it. Unlike
* PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail
* that silently drops repeats is not one.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class MpesaCallbackResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Null when the callback could not be matched to an outbound request. */
@ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request")
private MpesaRequest Request;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
/** The callback exactly as it arrived. */
@Column(columnDefinition = "TEXT")
private String RawPayload;
/** False when no matching request could be found — an orphan worth alerting on. */
@Column(nullable = false)
private boolean Matched;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,68 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* One outbound call to M-Pesa, recorded verbatim: what we sent, where, and on whose
* behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path
* waits for this row.
*/
@Entity
@Getter
@Setter
@ToString
public class MpesaRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Market-qualified provider, e.g. MPESA_KE. */
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Which call this was: STK_PUSH, STK_QUERY, TOKEN. */
@Column(nullable = false, length = 40)
private String Operation;
/**
* The payment attempt this call belongs to; null for calls with no payment
* (token fetches). A string, not the numeric id, so it can carry a NanoID.
*/
@Column(length = 5000)
private String InitiationId;
/**
* The provider's own reference once known, so a later callback can be tied back
* to the exact call that produced it.
*/
@Column(length = 100)
private String ProviderReference;
@Column(length = 10)
private String HttpMethod;
@Column(length = 512)
private String Url;
/** Outbound body as sent, JSON. Credentials are redacted before this is stored. */
@Column(columnDefinition = "TEXT")
private String RequestBody;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,52 @@
package com.test.payment.models.audit;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* What M-Pesa answered a MpesaRequest with — including the failures, which is
* usually what an audit is actually needed for.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class MpesaResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@ManyToOne(targetEntity = MpesaRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request", nullable = false)
private MpesaRequest Request;
/** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */
private Integer HttpStatus;
/** Response body verbatim, JSON. */
@Column(columnDefinition = "TEXT")
private String ResponseBody;
/** Exception summary when the call failed outright. */
@Column(length = 512)
private String Error;
/** Round-trip time, for spotting M-Pesa degrading before it starts erroring. */
private Long DurationMs;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,78 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.Instant;
/**
* An asynchronous result callback from MTN MoMo, stored whole alongside the fields
* worth querying on, and tied back to the call that caused it. Unlike
* PAYMENT_CALLBACKS this keeps every callback, duplicates included — an audit trail
* that silently drops repeats is not one.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class MtnCallbackResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Null when the callback could not be matched to an outbound request. */
@ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request")
private MtnRequest Request;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
@Column(length = 100)
private String ProviderReference;
@Column(length = 30)
private String ResultCode;
@Column(length = 255)
private String ResultDesc;
@Column(length = 50)
private String ReceiptNumber;
@Column(precision = 10, scale = 2)
private BigDecimal Amount;
@Column(length = 15)
private String PhoneNumber;
@Column(length = 20)
private String TransactionDate;
/** The callback exactly as it arrived. */
@Column(columnDefinition = "TEXT")
private String RawPayload;
/** False when no matching request could be found — an orphan worth alerting on. */
@Column(nullable = false)
private boolean Matched;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,68 @@
package com.test.payment.models.audit;
import com.test.payment.models.PaymentProviderType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* One outbound call to MTN MoMo, recorded verbatim: what we sent, where, and on whose
* behalf. Written asynchronously by ProviderCallAudit — nothing on the payment path
* waits for this row.
*/
@Entity
@Getter
@Setter
@ToString
public class MtnRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
/** Market-qualified provider, e.g. MTN_UG. */
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentProviderType Provider;
/** Which call this was: REQUEST_TO_PAY, STATUS_QUERY, TOKEN. */
@Column(nullable = false, length = 40)
private String Operation;
/**
* The payment attempt this call belongs to; null for calls with no payment
* (token fetches). A string, not the numeric id, so it can carry a NanoID.
*/
@Column(length = 36)
private String InitiationId;
/**
* The provider's own reference once known, so a later callback can be tied back
* to the exact call that produced it.
*/
@Column(length = 100)
private String ProviderReference;
@Column(length = 10)
private String HttpMethod;
@Column(length = 512)
private String Url;
/** Outbound body as sent, JSON. Credentials are redacted before this is stored. */
@Column(columnDefinition = "TEXT")
private String RequestBody;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,52 @@
package com.test.payment.models.audit;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
/**
* What MTN MoMo answered a MtnRequest with — including the failures, which is
* usually what an audit is actually needed for.
*/
@Entity
@Getter
@Setter
@ToString(exclude = "Request")
public class MtnResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@ManyToOne(targetEntity = MtnRequest.class, fetch = FetchType.LAZY)
@JoinColumn(name = "Request", nullable = false)
private MtnRequest Request;
/** HTTP status, or null when the call never got an answer (timeout, DNS, reset). */
private Integer HttpStatus;
/** Response body verbatim, JSON. */
@Column(columnDefinition = "TEXT")
private String ResponseBody;
/** Exception summary when the call failed outright. */
@Column(length = 512)
private String Error;
/** Round-trip time, for spotting MTN MoMo degrading before it starts erroring. */
private Long DurationMs;
@Column(nullable = false)
private Instant CreatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.test.payment.repository;
import com.test.payment.models.AirtelPaymentCallback;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface AirtelPaymentCallbackRepository extends JpaRepository<AirtelPaymentCallback, Long> {
@Query("SELECT c FROM AirtelPaymentCallback c WHERE c.Initiation.Id = :initiationId")
Optional<AirtelPaymentCallback> findByInitiationId(@Param("initiationId") Long initiationId);
}

View File

@@ -0,0 +1,23 @@
package com.test.payment.repository;
import com.test.payment.models.AirtelPaymentResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface AirtelPaymentResponseRepository extends JpaRepository<AirtelPaymentResponse, Long> {
@Query("SELECT r FROM AirtelPaymentResponse r WHERE r.Initiation.Id = :initiationId")
Optional<AirtelPaymentResponse> findByInitiationId(@Param("initiationId") Long initiationId);
@Query("SELECT r FROM AirtelPaymentResponse r WHERE r.ProviderReference = :providerReference")
Optional<AirtelPaymentResponse> findByProviderReference(@Param("providerReference") String providerReference);
}

View File

@@ -0,0 +1,20 @@
package com.test.payment.repository;
import com.test.payment.models.MpesaPaymentCallback;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MpesaPaymentCallbackRepository extends JpaRepository<MpesaPaymentCallback, Long> {
@Query("SELECT c FROM MpesaPaymentCallback c WHERE c.Initiation.Id = :initiationId")
Optional<MpesaPaymentCallback> findByInitiationId(@Param("initiationId") Long initiationId);
}

View File

@@ -0,0 +1,23 @@
package com.test.payment.repository;
import com.test.payment.models.MpesaPaymentResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MpesaPaymentResponseRepository extends JpaRepository<MpesaPaymentResponse, Long> {
@Query("SELECT r FROM MpesaPaymentResponse r WHERE r.Initiation.Id = :initiationId")
Optional<MpesaPaymentResponse> findByInitiationId(@Param("initiationId") Long initiationId);
@Query("SELECT r FROM MpesaPaymentResponse r WHERE r.ProviderReference = :providerReference")
Optional<MpesaPaymentResponse> findByProviderReference(@Param("providerReference") String providerReference);
}

View File

@@ -0,0 +1,20 @@
package com.test.payment.repository;
import com.test.payment.models.MtnPaymentCallback;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MtnPaymentCallbackRepository extends JpaRepository<MtnPaymentCallback, Long> {
@Query("SELECT c FROM MtnPaymentCallback c WHERE c.Initiation.Id = :initiationId")
Optional<MtnPaymentCallback> findByInitiationId(@Param("initiationId") Long initiationId);
}

View File

@@ -0,0 +1,23 @@
package com.test.payment.repository;
import com.test.payment.models.MtnPaymentResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MtnPaymentResponseRepository extends JpaRepository<MtnPaymentResponse, Long> {
@Query("SELECT r FROM MtnPaymentResponse r WHERE r.Initiation.Id = :initiationId")
Optional<MtnPaymentResponse> findByInitiationId(@Param("initiationId") Long initiationId);
@Query("SELECT r FROM MtnPaymentResponse r WHERE r.ProviderReference = :providerReference")
Optional<MtnPaymentResponse> findByProviderReference(@Param("providerReference") String providerReference);
}

View File

@@ -1,12 +0,0 @@
package com.test.payment.repository;
import com.test.payment.models.PaymentCallback;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public interface PaymentCallbackRepository extends ReactiveCrudRepository<PaymentCallback, Long> {
Mono<PaymentCallback> findByInitiationId(Long initiationId);
}

View File

@@ -1,39 +1,54 @@
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 com.test.payment.models.PaymentProviderType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface PaymentInitiationRepository extends ReactiveCrudRepository<PaymentInitiation, Long> {
public interface PaymentInitiationRepository extends JpaRepository<PaymentInitiation, Long> {
Flux<PaymentInitiation> findByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff);
@Query("""
SELECT i FROM PaymentInitiation i
WHERE i.Status.Name = :statusName AND i.CreatedAt < :cutoff
""")
List<PaymentInitiation> findByStatusNameAndCreatedAtBefore(@Param("statusName") String statusName,
@Param("cutoff") 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
* 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
SELECT COALESCE(SUM(i.Amount), 0) FROM PaymentInitiation i
WHERE i.Provider = :provider AND i.PhoneNumber = :phoneNumber
AND i.Status.Name <> :failedStatus AND i.CreatedAt >= :since
""")
Mono<BigDecimal> sumAmountInWindow(String provider, String phoneNumber, LocalDateTime since);
BigDecimal sumAmountInWindow(@Param("provider") PaymentProviderType provider,
@Param("phoneNumber") String phoneNumber,
@Param("since") LocalDateTime since,
@Param("failedStatus") String failedStatus);
/**
* 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
SELECT COALESCE(SUM(i.Amount), 0) FROM PaymentInitiation i
WHERE i.Provider = :provider AND i.Status.Name <> :failedStatus AND i.CreatedAt >= :since
""")
Mono<BigDecimal> sumAmountInWindowForProvider(String provider, LocalDateTime since);
BigDecimal sumAmountInWindowForProvider(@Param("provider") PaymentProviderType provider,
@Param("since") LocalDateTime since,
@Param("failedStatus") String failedStatus);
}

View File

@@ -1,14 +0,0 @@
package com.test.payment.repository;
import com.test.payment.models.PaymentResponse;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public interface PaymentResponseRepository extends ReactiveCrudRepository<PaymentResponse, Long> {
Mono<PaymentResponse> findByInitiationId(Long initiationId);
Mono<PaymentResponse> findByProviderReference(String providerReference);
}

View File

@@ -1,19 +1,36 @@
package com.test.payment.repository;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.ProviderLimit;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface ProviderLimitRepository extends ReactiveCrudRepository<ProviderLimit, Long> {
public interface ProviderLimitRepository extends JpaRepository<ProviderLimit, Long> {
Flux<ProviderLimit> findByProviderAndActiveTrue(String provider);
@Query("SELECT l FROM ProviderLimit l WHERE l.Provider = :provider AND l.Active = TRUE")
List<ProviderLimit> findByProviderAndActiveTrue(@Param("provider") PaymentProviderType provider);
Flux<ProviderLimit> findByProviderOrderByPeriod(String provider);
@Query("SELECT l FROM ProviderLimit l WHERE l.Provider = :provider ORDER BY l.Period")
List<ProviderLimit> findByProviderOrderByPeriod(@Param("provider") PaymentProviderType provider);
Flux<ProviderLimit> findAllByOrderByProviderAscPeriodAsc();
@Query("SELECT l FROM ProviderLimit l ORDER BY l.Provider ASC, l.Period ASC")
List<ProviderLimit> findAllByOrderByProviderAscPeriodAsc();
Mono<ProviderLimit> findByProviderAndPeriodAndScope(String provider, String period, String scope);
}
@Query("""
SELECT l FROM ProviderLimit l
WHERE l.Provider = :provider AND l.Period = :period AND l.Scope = :scope
""")
Optional<ProviderLimit> findByProviderAndPeriodAndScope(@Param("provider") PaymentProviderType provider,
@Param("period") String period,
@Param("scope") String scope);
}

View File

@@ -1,16 +1,35 @@
package com.test.payment.repository;
import com.test.payment.models.Operator;
import com.test.payment.models.ProviderToken;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface ProviderTokenRepository extends ReactiveCrudRepository<ProviderToken, Long> {
public interface ProviderTokenRepository extends JpaRepository<ProviderToken, Long> {
Mono<ProviderToken> findFirstByProviderAndExpiresAtAfterOrderByIdDesc(String provider, LocalDateTime cutoff);
/** Newest usable token first; the caller takes the head. */
@Query("""
SELECT t FROM ProviderToken t
WHERE t.Provider = :provider AND t.ExpiresAt > :cutoff
ORDER BY t.Id DESC
""")
List<ProviderToken> findUsable(@Param("provider") Operator provider,
@Param("cutoff") LocalDateTime cutoff);
Mono<Void> deleteByProvider(String provider);
@Modifying
@Transactional
@Query("DELETE FROM ProviderToken t WHERE t.Provider = :provider")
void deleteByProvider(@Param("provider") Operator provider);
}

View File

@@ -0,0 +1,20 @@
package com.test.payment.repository;
import com.test.payment.models.Status;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface StatusRepository extends JpaRepository<Status, Long> {
@Query("SELECT s FROM Status s WHERE s.Name = :name")
Optional<Status> findByName(@Param("name") String name);
}

View File

@@ -1,17 +1,47 @@
package com.test.payment.repository;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Transaction;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Optional;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*
* <p>The listing queries fetch-join every association. They have to: the
* associations are LAZY and serialization happens in the web layer, after the
* transaction has closed, so anything left as a proxy would blow up there.
*/
@Repository
public interface TransactionRepository extends ReactiveCrudRepository<Transaction, Long> {
public interface TransactionRepository extends JpaRepository<Transaction, Long> {
Mono<Transaction> findByInitiationId(Long initiationId);
String GRAPH = """
LEFT JOIN FETCH t.Initiation i
LEFT JOIN FETCH i.Status
LEFT JOIN FETCH t.Status
LEFT JOIN FETCH t.MpesaRequest
LEFT JOIN FETCH t.AirtelRequest
LEFT JOIN FETCH t.MtnRequest
LEFT JOIN FETCH t.MpesaCallback
LEFT JOIN FETCH t.AirtelCallback
LEFT JOIN FETCH t.MtnCallback
""";
Flux<Transaction> findByProvider(String provider);
@Query("SELECT t FROM Transaction t WHERE t.Initiation.Id = :initiationId")
Optional<Transaction> findByInitiationId(@Param("initiationId") Long initiationId);
Flux<Transaction> findByStatus(String status);
@Query("SELECT t FROM Transaction t " + GRAPH)
List<Transaction> findAllWithAssociations();
@Query("SELECT t FROM Transaction t " + GRAPH + " WHERE t.Provider = :provider")
List<Transaction> findByProvider(@Param("provider") PaymentProviderType provider);
@Query("SELECT t FROM Transaction t WHERE t.Status.Name = :statusName")
List<Transaction> findByStatusName(@Param("statusName") String statusName);
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.AirtelCallbackResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AirtelCallbackResponseRepository extends JpaRepository<AirtelCallbackResponse, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.AirtelRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface AirtelRequestRepository extends JpaRepository<AirtelRequest, Long> {
/** Newest first; callers take the head. Ordering replaces a derived findFirst. */
@Query("SELECT r FROM AirtelRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC")
List<AirtelRequest> findByProviderReference(@Param("providerReference") String providerReference);
/** Oldest first, so the head is the call that initiated the payment. */
@Query("SELECT r FROM AirtelRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC")
List<AirtelRequest> findByInitiationId(@Param("initiationId") String initiationId);
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.AirtelResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AirtelResponseRepository extends JpaRepository<AirtelResponse, Long> {
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MpesaCallbackResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MpesaCallbackResponseRepository extends JpaRepository<MpesaCallbackResponse, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MpesaRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MpesaRequestRepository extends JpaRepository<MpesaRequest, Long> {
/** Newest first; callers take the head. Ordering replaces a derived findFirst. */
@Query("SELECT r FROM MpesaRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC")
List<MpesaRequest> findByProviderReference(@Param("providerReference") String providerReference);
/** Oldest first, so the head is the call that initiated the payment. */
@Query("SELECT r FROM MpesaRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC")
List<MpesaRequest> findByInitiationId(@Param("initiationId") String initiationId);
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MpesaResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MpesaResponseRepository extends JpaRepository<MpesaResponse, Long> {
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MtnCallbackResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MtnCallbackResponseRepository extends JpaRepository<MtnCallbackResponse, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MtnRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Queries are explicit JPQL, not derived from method names: the entity's fields are
* PascalCase, so a derived name cannot resolve the property path.
*/
@Repository
public interface MtnRequestRepository extends JpaRepository<MtnRequest, Long> {
/** Newest first; callers take the head. Ordering replaces a derived findFirst. */
@Query("SELECT r FROM MtnRequest r WHERE r.ProviderReference = :providerReference ORDER BY r.Id DESC")
List<MtnRequest> findByProviderReference(@Param("providerReference") String providerReference);
/** Oldest first, so the head is the call that initiated the payment. */
@Query("SELECT r FROM MtnRequest r WHERE r.InitiationId = :initiationId ORDER BY r.Id ASC")
List<MtnRequest> findByInitiationId(@Param("initiationId") String initiationId);
}

View File

@@ -0,0 +1,9 @@
package com.test.payment.repository.audit;
import com.test.payment.models.audit.MtnResponse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MtnResponseRepository extends JpaRepository<MtnResponse, Long> {
}

View File

@@ -9,12 +9,14 @@ import com.test.payment.dto.PaymentRequest;
import com.test.payment.dto.PaymentResultDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.exceptions.ProviderBusyException;
import com.test.payment.models.Country;
import com.test.payment.models.Operator;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.PaymentResponse;
import com.test.payment.models.TransactionStatus;
import com.test.payment.models.Status;
import com.test.payment.service.PaymentLifecycleService.CallbackData;
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
import com.test.payment.service.PaymentLifecycleService.StoredResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
@@ -35,13 +37,16 @@ public class AirtelService implements PaymentProviderService {
private final AirtelClient airtelClient;
private final PaymentLifecycleService lifecycle;
private final ProviderMarkets markets;
private final ProviderCallAudit audit;
private final StatusCatalog statuses;
private final PaymentLimitService limits;
private final Environment environment;
private final ObjectMapper objectMapper;
@Override
public String provider() {
return PaymentProviderType.AIRTEL.name();
public PaymentProviderType provider() {
return markets.resolve(Operator.AIRTEL);
}
@Override
@@ -50,7 +55,13 @@ public class AirtelService implements PaymentProviderService {
.then(lifecycle.saveInitiation(provider(), request))
.flatMap(initiation -> Mono.defer(() -> {
String reference = "ATL" + UUID.randomUUID().toString().replace("-", "");
return airtelClient.pay(buildRequest(request, reference))
AirtelPaymentRequestDto payload = buildRequest(request, reference);
ProviderCallAudit.Handle call = audit.begin(provider(), "USSD_PUSH", String.valueOf(initiation.getId()),
"POST", baseUrl() + "/merchant/v1/payments/", payload);
audit.linkReference(call, reference);
return airtelClient.pay(payload)
.doOnNext(response -> audit.complete(call, 200, response, null))
.doOnError(ex -> audit.complete(call, null, null, ex))
.map(response -> toResponseData(response, reference));
})
.flatMap(data -> lifecycle.persistResponse(initiation, data))
@@ -71,7 +82,11 @@ public class AirtelService implements PaymentProviderService {
transaction.getAirtelMoneyId(),
null, null, null,
success);
return lifecycle.applyCallback(provider(), data, toJson(payload));
String raw = toJson(payload);
audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit(
data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(),
data.amount(), data.phoneNumber(), data.transactionDate()), raw);
return lifecycle.applyCallback(provider(), data, raw);
}
@Override
@@ -79,8 +94,12 @@ public class AirtelService implements PaymentProviderService {
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
}
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
return airtelClient.status(response.getProviderReference())
private Mono<QueryOutcome> queryProvider(StoredResponse response) {
ProviderCallAudit.Handle call = audit.begin(provider(), "STATUS_QUERY", String.valueOf(response.initiationId()),
"GET", baseUrl() + "/standard/v1/payments/" + response.providerReference(), null);
return airtelClient.status(response.providerReference())
.doOnNext(result -> audit.complete(call, 200, result, null))
.doOnError(ex -> audit.complete(call, null, null, ex))
.map(result -> {
AirtelResponseDto.Transaction tx = result.getData() != null ? result.getData().getTransaction() : null;
String status = tx != null ? tx.getStatus() : null;
@@ -91,17 +110,22 @@ public class AirtelService implements PaymentProviderService {
tx != null ? tx.getAirtelMoneyId() : null);
})
.onErrorResume(ProviderBusyException.class,
e -> Mono.just(QueryOutcome.pending("Airtel status query rate-limited — showing last known state")));
e -> Mono.just(QueryOutcome.pending(statuses.pending(), "Airtel status query rate-limited — showing last known state")));
}
private String baseUrl() {
return environment.getProperty("airtel.base-url", "");
}
private AirtelPaymentRequestDto buildRequest(PaymentRequest request, String reference) {
String country = environment.getProperty("airtel.country", "KE");
String currency = environment.getProperty("airtel.currency", "KES");
Country country = markets.resolve(Operator.AIRTEL).country();
// the market's own currency unless airtel.currency deliberately overrides it
String currency = environment.getProperty("airtel.currency", country.currency());
return new AirtelPaymentRequestDto(
request.getAccountReference(),
new AirtelPaymentRequestDto.Subscriber(country, currency, request.getPhoneNumber()),
new AirtelPaymentRequestDto.Subscriber(country.name(), currency, request.getPhoneNumber()),
new AirtelPaymentRequestDto.Transaction(
String.valueOf(request.getAmount()), country, currency, reference));
String.valueOf(request.getAmount()), country.name(), currency, reference));
}
private ProviderResponseData toResponseData(AirtelResponseDto response, String reference) {
@@ -119,14 +143,14 @@ public class AirtelService implements PaymentProviderService {
accepted);
}
private TransactionStatus mapStatus(String airtelStatus) {
private Status mapStatus(String airtelStatus) {
if ("TS".equalsIgnoreCase(airtelStatus)) {
return TransactionStatus.SUCCESS;
return statuses.success();
}
if ("TF".equalsIgnoreCase(airtelStatus)) {
return TransactionStatus.FAILED;
return statuses.failed();
}
return TransactionStatus.PENDING; // TIP or unknown — keep waiting
return statuses.pending(); // TIP or unknown — keep waiting
}
private String toJson(Object value) {

View File

@@ -1,7 +1,7 @@
package com.test.payment.service;
import com.test.payment.dto.OAuth2TokenResponse;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Operator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
@@ -21,11 +21,11 @@ public class AirtelTokenService {
private final Environment environment;
public Mono<String> getToken() {
return tokenCache.getToken(PaymentProviderType.AIRTEL.name(), this::fetchToken);
return tokenCache.getToken(Operator.AIRTEL, this::fetchToken);
}
public Mono<Void> evictToken() {
return tokenCache.evictToken(PaymentProviderType.AIRTEL.name());
return tokenCache.evictToken(Operator.AIRTEL);
}
private Mono<TokenCacheService.FetchedToken> fetchToken() {

View File

@@ -11,12 +11,13 @@ import com.test.payment.dto.StkQueryRequestDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.exceptions.ProviderBusyException;
import com.test.payment.exceptions.ProviderProcessingException;
import com.test.payment.models.Operator;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.PaymentResponse;
import com.test.payment.models.TransactionStatus;
import com.test.payment.models.Status;
import com.test.payment.service.PaymentLifecycleService.CallbackData;
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
import com.test.payment.service.PaymentLifecycleService.StoredResponse;
import com.test.payment.utils.MpesaUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -36,20 +37,33 @@ public class MpesaService implements PaymentProviderService {
private final MpesaClient mpesaClient;
private final PaymentLifecycleService lifecycle;
private final ProviderMarkets markets;
private final ProviderCallAudit audit;
private final StatusCatalog statuses;
private final PaymentLimitService limits;
private final Environment environment;
private final ObjectMapper objectMapper;
@Override
public String provider() {
return PaymentProviderType.MPESA.name();
public PaymentProviderType provider() {
return markets.resolve(Operator.MPESA);
}
@Override
public Mono<PaymentResultDto> initiatePayment(PaymentRequest request) {
return limits.enforce(provider(), request)
.then(lifecycle.saveInitiation(provider(), request))
.flatMap(initiation -> Mono.defer(() -> mpesaClient.stkPush(buildStkRequest(request)))
.flatMap(initiation -> Mono.defer(() -> {
MpesaRequestDto stk = buildStkRequest(request);
ProviderCallAudit.Handle call = audit.begin(provider(), "STK_PUSH", String.valueOf(initiation.getId()),
"POST", stkPushUrl(), stk);
return mpesaClient.stkPush(stk)
.doOnNext(response -> {
audit.complete(call, 200, response, null);
audit.linkReference(call, response.getCheckoutRequestId());
})
.doOnError(ex -> audit.complete(call, null, null, ex));
})
.flatMap(response -> lifecycle.persistResponse(initiation, toResponseData(response)))
.onErrorResume(ex -> lifecycle.markFailed(initiation, ex)));
}
@@ -71,7 +85,11 @@ public class MpesaService implements PaymentProviderService {
asString(metadata.get("PhoneNumber")),
asString(metadata.get("TransactionDate")),
success);
return lifecycle.applyCallback(provider(), data, toJson(payload));
String raw = toJson(payload);
audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit(
data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(),
data.amount(), data.phoneNumber(), data.transactionDate()), raw);
return lifecycle.applyCallback(provider(), data, raw);
}
@Override
@@ -79,20 +97,31 @@ public class MpesaService implements PaymentProviderService {
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
}
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
private Mono<QueryOutcome> queryProvider(StoredResponse response) {
String shortCode = environment.getProperty("mpesa.business-short-code");
String passkey = environment.getProperty("mpesa.pass-key");
MpesaUtils.MpesaAuthData auth = MpesaUtils.generateAuthData(shortCode, passkey);
StkQueryRequestDto query = new StkQueryRequestDto(
Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.getProviderReference());
Long.valueOf(shortCode), auth.getPassword(), auth.getTimestamp(), response.providerReference());
ProviderCallAudit.Handle call = audit.begin(provider(), "STK_QUERY", null, "POST", stkQueryUrl(), query);
return mpesaClient.stkQuery(query)
.doOnNext(result -> audit.complete(call, 200, result, null))
.doOnError(ex -> audit.complete(call, null, null, ex))
.map(result -> new QueryOutcome(
mapQueryResult(result.getResultCode()), result.getResultCode(), result.getResultDesc(), null))
.onErrorResume(ProviderProcessingException.class,
e -> Mono.just(QueryOutcome.pending("Transaction is still being processed by M-Pesa")))
e -> Mono.just(QueryOutcome.pending(statuses.pending(), "Transaction is still being processed by M-Pesa")))
.onErrorResume(ProviderBusyException.class,
e -> Mono.just(QueryOutcome.pending("M-Pesa status query rate-limited — showing last known state")));
e -> Mono.just(QueryOutcome.pending(statuses.pending(), "M-Pesa status query rate-limited — showing last known state")));
}
private String stkPushUrl() {
return environment.getProperty("mpesa.base-url", "") + "/mpesa/stkpush/v1/processrequest";
}
private String stkQueryUrl() {
return environment.getProperty("mpesa.base-url", "") + "/mpesa/stkpushquery/v1/query";
}
private MpesaRequestDto buildStkRequest(PaymentRequest request) {
@@ -127,11 +156,11 @@ public class MpesaService implements PaymentProviderService {
accepted);
}
private TransactionStatus mapQueryResult(String resultCode) {
private Status mapQueryResult(String resultCode) {
if (resultCode == null) {
return TransactionStatus.PENDING;
return statuses.pending();
}
return "0".equals(resultCode) ? TransactionStatus.SUCCESS : TransactionStatus.FAILED;
return "0".equals(resultCode) ? statuses.success() : statuses.failed();
}
private Map<String, Object> extractMetadata(StkCallbackPayload.StkCallbackBody callback) {

View File

@@ -1,7 +1,7 @@
package com.test.payment.service;
import com.test.payment.dto.MpesaTokenResponse;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Operator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
@@ -19,11 +19,11 @@ public class MpesaTokenService {
private final Environment environment;
public Mono<String> getToken() {
return tokenCache.getToken(PaymentProviderType.MPESA.name(), this::fetchToken);
return tokenCache.getToken(Operator.MPESA, this::fetchToken);
}
public Mono<Void> evictToken() {
return tokenCache.evictToken(PaymentProviderType.MPESA.name());
return tokenCache.evictToken(Operator.MPESA);
}
private Mono<TokenCacheService.FetchedToken> fetchToken() {

View File

@@ -8,12 +8,13 @@ import com.test.payment.dto.PaymentRequest;
import com.test.payment.dto.PaymentResultDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.exceptions.ProviderBusyException;
import com.test.payment.models.Operator;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.PaymentResponse;
import com.test.payment.models.TransactionStatus;
import com.test.payment.models.Status;
import com.test.payment.service.PaymentLifecycleService.CallbackData;
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
import com.test.payment.service.PaymentLifecycleService.StoredResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
@@ -36,13 +37,16 @@ public class MtnService implements PaymentProviderService {
private final MtnClient mtnClient;
private final PaymentLifecycleService lifecycle;
private final ProviderMarkets markets;
private final ProviderCallAudit audit;
private final StatusCatalog statuses;
private final PaymentLimitService limits;
private final Environment environment;
private final ObjectMapper objectMapper;
@Override
public String provider() {
return PaymentProviderType.MTN.name();
public PaymentProviderType provider() {
return markets.resolve(Operator.MTN);
}
@Override
@@ -51,8 +55,14 @@ public class MtnService implements PaymentProviderService {
.then(lifecycle.saveInitiation(provider(), request))
.flatMap(initiation -> Mono.defer(() -> {
String reference = UUID.randomUUID().toString();
MtnPayRequestDto payload = buildRequest(request, reference);
ProviderCallAudit.Handle call = audit.begin(provider(), "REQUEST_TO_PAY",
String.valueOf(initiation.getId()), "POST", baseUrl() + "/collection/v1_0/requesttopay", payload);
audit.linkReference(call, reference);
// 202 Accepted, empty body — the reference is all we get back
return mtnClient.requestToPay(reference, buildRequest(request, reference))
return mtnClient.requestToPay(reference, payload)
.doOnError(ex -> audit.complete(call, null, null, ex))
.doOnSuccess(ignored -> audit.complete(call, 202, "<202 Accepted, empty body>", null))
.thenReturn(new ProviderResponseData(
reference, null, "202", "Accepted", "Request to pay accepted", true));
})
@@ -76,7 +86,11 @@ public class MtnService implements PaymentProviderService {
payload.getPayer() != null ? payload.getPayer().getPartyId() : null,
null,
success);
return lifecycle.applyCallback(provider(), data, toJson(payload));
String raw = toJson(payload);
audit.recordCallback(provider(), new ProviderCallAudit.CallbackAudit(
data.providerReference(), data.resultCode(), data.resultDesc(), data.receiptNumber(),
data.amount(), data.phoneNumber(), data.transactionDate()), raw);
return lifecycle.applyCallback(provider(), data, raw);
}
@Override
@@ -84,15 +98,23 @@ public class MtnService implements PaymentProviderService {
return lifecycle.checkStatus(provider(), providerReference, this::queryProvider);
}
private Mono<QueryOutcome> queryProvider(PaymentResponse response) {
return mtnClient.status(response.getProviderReference())
private Mono<QueryOutcome> queryProvider(StoredResponse response) {
ProviderCallAudit.Handle call = audit.begin(provider(), "STATUS_QUERY", String.valueOf(response.initiationId()), "GET",
baseUrl() + "/collection/v1_0/requesttopay/" + response.providerReference(), null);
return mtnClient.status(response.providerReference())
.doOnNext(result -> audit.complete(call, 200, result, null))
.doOnError(ex -> audit.complete(call, null, null, ex))
.map(result -> new QueryOutcome(
mapStatus(result.getStatus()),
result.getStatus(),
"SUCCESSFUL".equalsIgnoreCase(result.getStatus()) ? "Payment successful" : result.reasonText(),
result.getFinancialTransactionId()))
.onErrorResume(ProviderBusyException.class,
e -> Mono.just(QueryOutcome.pending("MTN status query rate-limited — showing last known state")));
e -> Mono.just(QueryOutcome.pending(statuses.pending(), "MTN status query rate-limited — showing last known state")));
}
private String baseUrl() {
return environment.getProperty("mtn.base-url", "");
}
private MtnPayRequestDto buildRequest(PaymentRequest request, String reference) {
@@ -105,14 +127,14 @@ public class MtnService implements PaymentProviderService {
request.getAccountReference());
}
private TransactionStatus mapStatus(String mtnStatus) {
private Status mapStatus(String mtnStatus) {
if ("SUCCESSFUL".equalsIgnoreCase(mtnStatus)) {
return TransactionStatus.SUCCESS;
return statuses.success();
}
if ("FAILED".equalsIgnoreCase(mtnStatus)) {
return TransactionStatus.FAILED;
return statuses.failed();
}
return TransactionStatus.PENDING;
return statuses.pending();
}
private String toJson(Object value) {

View File

@@ -1,7 +1,7 @@
package com.test.payment.service;
import com.test.payment.dto.OAuth2TokenResponse;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Operator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
@@ -19,11 +19,11 @@ public class MtnTokenService {
private final Environment environment;
public Mono<String> getToken() {
return tokenCache.getToken(PaymentProviderType.MTN.name(), this::fetchToken);
return tokenCache.getToken(Operator.MTN, this::fetchToken);
}
public Mono<Void> evictToken() {
return tokenCache.evictToken(PaymentProviderType.MTN.name());
return tokenCache.evictToken(Operator.MTN);
}
private Mono<TokenCacheService.FetchedToken> fetchToken() {

View File

@@ -4,151 +4,105 @@ import com.test.payment.dto.CallbackAckDto;
import com.test.payment.dto.PaymentRequest;
import com.test.payment.dto.PaymentResultDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.models.PaymentCallback;
import com.test.payment.models.PaymentInitiation;
import com.test.payment.models.PaymentResponse;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Status;
import com.test.payment.models.Transaction;
import com.test.payment.models.TransactionStatus;
import com.test.payment.repository.PaymentCallbackRepository;
import com.test.payment.repository.PaymentInitiationRepository;
import com.test.payment.repository.PaymentResponseRepository;
import com.test.payment.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
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 reactor.core.scheduler.Schedulers;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.function.Function;
/**
* Provider-agnostic persistence and lifecycle: initiation → response → callback /
* status query → consolidated transaction. Provider services delegate here and only
* contribute the provider-specific HTTP calls and payload parsing.
*
* <p>Persistence is JPA, which blocks, so this class is a thin reactive facade: it
* hands each unit of work to {@link PaymentLifecycleStore} on a bounded-elastic
* thread and never touches a repository on the event loop. Provider HTTP calls stay
* outside the transaction — the store is only entered before and after them.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentLifecycleService {
private final PaymentInitiationRepository initiationRepository;
private final PaymentResponseRepository responseRepository;
private final PaymentCallbackRepository callbackRepository;
private final TransactionRepository transactionRepository;
private final PaymentLifecycleStore store;
/** The provider's answer to an initiation. */
public record ProviderResponseData(String providerReference, String secondaryReference, String responseCode,
String responseDescription, String customerMessage, boolean accepted) {
}
/**
* A stored provider response, read back provider-neutrally. The response tables
* are per operator and share no supertype, so the lifecycle passes this view
* around rather than an entity.
*/
public record StoredResponse(Long initiationId, PaymentProviderType provider, String providerReference,
String secondaryReference, String responseCode, String responseDescription,
String customerMessage) {
}
/** A parsed provider callback. */
public record CallbackData(String providerReference, String resultCode, String resultDesc, String receiptNumber,
BigDecimal amount, String phoneNumber, String transactionDate, boolean success) {
}
/** The outcome of a live status query. */
public record QueryOutcome(TransactionStatus newStatus, String resultCode, String resultDesc, String receiptNumber) {
public static QueryOutcome pending(String note) {
return new QueryOutcome(TransactionStatus.PENDING, null, note, null);
/** The outcome of a live status query. {@code newStatus} is a row from STATUSES. */
public record QueryOutcome(Status newStatus, String resultCode, String resultDesc, String receiptNumber) {
/** Still open — the provider had no verdict yet, so the payment stays as it is. */
public static QueryOutcome pending(Status pending, String note) {
return new QueryOutcome(pending, null, note, null);
}
}
public Mono<PaymentInitiation> saveInitiation(String provider, PaymentRequest request) {
return initiationRepository.save(PaymentInitiation.builder()
.provider(provider)
.phoneNumber(request.getPhoneNumber())
.amount(BigDecimal.valueOf(request.getAmount()))
.accountReference(request.getAccountReference())
.transactionDesc(request.getTransactionDesc())
.status(TransactionStatus.PENDING.name())
.createdAt(LocalDateTime.now())
.build());
public Mono<PaymentInitiation> saveInitiation(PaymentProviderType provider, PaymentRequest request) {
return blocking(() -> store.saveInitiation(provider, request));
}
public Mono<PaymentResultDto> persistResponse(PaymentInitiation initiation, ProviderResponseData data) {
String newStatus = data.accepted() ? TransactionStatus.PENDING.name() : TransactionStatus.FAILED.name();
PaymentResponse entity = PaymentResponse.builder()
.initiationId(initiation.getId())
.provider(initiation.getProvider())
.providerReference(data.providerReference())
.secondaryReference(data.secondaryReference())
.responseCode(data.responseCode())
.responseDescription(data.responseDescription())
.customerMessage(data.customerMessage())
.createdAt(LocalDateTime.now())
.build();
// one response per initiation — an existing row wins, a concurrent insert falls back to it
return responseRepository.findByInitiationId(initiation.getId())
.switchIfEmpty(Mono.defer(() -> responseRepository.save(entity)
.onErrorResume(DuplicateKeyException.class,
e -> responseRepository.findByInitiationId(initiation.getId()))))
.flatMap(saved -> updateStatus(initiation, newStatus)
.flatMap(updated -> data.accepted()
? Mono.just(updated)
: recordTransaction(updated, saved, null,
data.responseDescription(), null, null, "REJECTION")
.thenReturn(updated))
.map(updated -> PaymentResultDto.builder()
.initiationId(updated.getId())
.provider(updated.getProvider())
.status(updated.getStatus())
.providerReference(saved.getProviderReference())
.secondaryReference(saved.getSecondaryReference())
.responseCode(saved.getResponseCode())
.responseDescription(saved.getResponseDescription())
.customerMessage(saved.getCustomerMessage())
.build()));
return blocking(() -> store.persistResponse(initiation.getId(), data));
}
public Mono<PaymentResultDto> markFailed(PaymentInitiation initiation, Throwable ex) {
log.error("[{}] payment failed for initiation {}: {}", initiation.getProvider(), initiation.getId(), ex.toString());
String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
return updateStatus(initiation, TransactionStatus.FAILED.name())
.flatMap(updated -> recordTransaction(updated, null, null, truncate(reason), null, null, "ERROR"))
.then(Mono.error(ex));
return blocking(() -> {
store.markFailed(initiation.getId(), reason);
return true;
}).then(Mono.error(ex));
}
public Mono<CallbackAckDto> applyCallback(String provider, CallbackData data, String rawPayload) {
public Mono<CallbackAckDto> applyCallback(PaymentProviderType provider, CallbackData data, String rawPayload) {
if (data.providerReference() == null) {
log.warn("[{}] callback without a provider reference ignored", provider);
return Mono.just(CallbackAckDto.accepted("Ignored: no reference"));
}
return responseRepository.findByProviderReference(data.providerReference())
.filter(response -> provider.equals(response.getProvider()))
.flatMap(response -> callbackRepository.findByInitiationId(response.getInitiationId())
.map(existing -> {
log.info("[{}] duplicate callback for {} ignored", provider, data.providerReference());
return CallbackAckDto.accepted("Duplicate callback ignored");
})
.switchIfEmpty(Mono.defer(() -> saveCallback(response, data, rawPayload))))
.switchIfEmpty(Mono.fromSupplier(() -> {
log.warn("[{}] callback for unknown reference {}", provider, data.providerReference());
return CallbackAckDto.accepted("Unknown reference");
}));
return blocking(() -> store.applyCallback(provider, data, rawPayload));
}
/**
* Returns the transaction state from the database; if it is still PENDING, runs the
* provider's live status query (querier) and applies the outcome.
* provider's live status query (querier) and applies the outcome. The query itself
* runs between two transactions, never inside one.
*/
public Mono<TransactionStatusDto> checkStatus(String provider, String providerReference,
Function<PaymentResponse, Mono<QueryOutcome>> querier) {
return responseRepository.findByProviderReference(providerReference)
.filter(response -> provider.equals(response.getProvider()))
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND,
"No " + provider + " transaction found for reference " + providerReference)))
.flatMap(response -> initiationRepository.findById(response.getInitiationId())
.flatMap(initiation -> TransactionStatus.PENDING.name().equals(initiation.getStatus())
? querier.apply(response).flatMap(outcome -> applyQueryOutcome(initiation, response, outcome))
: buildStatusDto(initiation, response)));
public Mono<TransactionStatusDto> checkStatus(PaymentProviderType provider, String providerReference,
Function<StoredResponse, Mono<QueryOutcome>> querier) {
return blocking(() -> store.loadForStatusCheck(provider, providerReference))
.flatMap(context -> context.pending()
? querier.apply(context.response())
.flatMap(outcome -> blocking(() ->
store.applyQueryOutcome(context.response().initiationId(), outcome)))
: Mono.just(context.currentState()));
}
/**
@@ -156,172 +110,27 @@ public class PaymentLifecycleService {
*/
public Mono<Void> reconcile(PaymentInitiation initiation,
Function<String, Mono<TransactionStatusDto>> statusChecker) {
return responseRepository.findByInitiationId(initiation.getId())
.flatMap(response -> {
if (response.getProviderReference() == null) {
return failTerminal(initiation, "No provider reference on response").thenReturn(true);
}
return statusChecker.apply(response.getProviderReference()).thenReturn(true);
})
.switchIfEmpty(Mono.defer(() -> {
log.warn("[{}] initiation {} never received a provider response — marking FAILED",
initiation.getProvider(), initiation.getId());
return failTerminal(initiation, "No provider response received").thenReturn(false);
}))
return blocking(() -> store.beginReconcile(initiation.getId()))
.flatMap(reference -> reference
.map(ref -> statusChecker.apply(ref).then())
.orElseGet(Mono::empty))
.then();
}
public Flux<Transaction> listTransactions(String provider) {
return provider == null ? transactionRepository.findAll() : transactionRepository.findByProvider(provider);
public Flux<Transaction> listTransactions(PaymentProviderType provider) {
return blocking(() -> store.listTransactions(provider)).flatMapMany(Flux::fromIterable);
}
private Mono<Transaction> failTerminal(PaymentInitiation initiation, String reason) {
return updateStatus(initiation, TransactionStatus.FAILED.name())
.flatMap(updated -> recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION"));
}
private Mono<CallbackAckDto> saveCallback(PaymentResponse response, CallbackData data, String rawPayload) {
PaymentCallback entity = PaymentCallback.builder()
.initiationId(response.getInitiationId())
.provider(response.getProvider())
.providerReference(data.providerReference())
.resultCode(data.resultCode())
.resultDesc(data.resultDesc())
.receiptNumber(data.receiptNumber())
.amount(data.amount())
.phoneNumber(data.phoneNumber())
.transactionDate(data.transactionDate())
.rawPayload(rawPayload)
.createdAt(LocalDateTime.now())
.build();
String newStatus = data.success() ? TransactionStatus.SUCCESS.name() : TransactionStatus.FAILED.name();
return callbackRepository.save(entity)
.onErrorResume(DuplicateKeyException.class,
e -> callbackRepository.findByInitiationId(response.getInitiationId()))
.flatMap(saved -> initiationRepository.findById(response.getInitiationId())
.flatMap(initiation -> updateStatus(initiation, newStatus))
.flatMap(updated -> recordTransaction(updated, response, data.resultCode(),
data.resultDesc(), data.receiptNumber(), data.transactionDate(), "CALLBACK")))
.doOnNext(tx -> log.info("[{}] callback processed for initiation {} — status {}",
tx.getProvider(), tx.getInitiationId(), tx.getStatus()))
.thenReturn(CallbackAckDto.accepted("Callback processed"));
}
private Mono<TransactionStatusDto> applyQueryOutcome(PaymentInitiation initiation, PaymentResponse response,
QueryOutcome outcome) {
if (outcome.newStatus() == TransactionStatus.PENDING) {
return buildStatusDto(initiation, response)
.map(dto -> {
if (outcome.resultDesc() != null) {
dto.setResultDesc(outcome.resultDesc());
}
return dto;
});
}
return updateStatus(initiation, outcome.newStatus().name())
.flatMap(updated -> recordTransaction(updated, response, outcome.resultCode(),
outcome.resultDesc(), outcome.receiptNumber(), null, "QUERY")
.then(buildStatusDto(updated, response)))
.map(dto -> {
dto.setResultCode(outcome.resultCode());
dto.setResultDesc(outcome.resultDesc());
if (outcome.receiptNumber() != null) {
dto.setReceiptNumber(outcome.receiptNumber());
}
return dto;
});
}
private Mono<TransactionStatusDto> buildStatusDto(PaymentInitiation initiation, PaymentResponse response) {
// result details come from the callback when we have one, otherwise from the
// consolidated transaction row (e.g. when a status query resolved the payment)
return callbackRepository.findByInitiationId(initiation.getId())
.map(Optional::of)
.defaultIfEmpty(Optional.empty())
.zipWith(transactionRepository.findByInitiationId(initiation.getId())
.map(Optional::of)
.defaultIfEmpty(Optional.empty()))
.map(tuple -> {
Optional<PaymentCallback> cb = tuple.getT1();
Optional<Transaction> tx = tuple.getT2();
return TransactionStatusDto.builder()
.initiationId(initiation.getId())
.provider(initiation.getProvider())
.providerReference(response.getProviderReference())
.secondaryReference(response.getSecondaryReference())
.status(initiation.getStatus())
.phoneNumber(initiation.getPhoneNumber())
.amount(initiation.getAmount())
.accountReference(initiation.getAccountReference())
.resultCode(cb.map(PaymentCallback::getResultCode)
.or(() -> tx.map(Transaction::getResultCode)).orElse(null))
.resultDesc(cb.map(PaymentCallback::getResultDesc)
.or(() -> tx.map(Transaction::getResultDesc)).orElse(null))
.receiptNumber(cb.map(PaymentCallback::getReceiptNumber)
.or(() -> tx.map(Transaction::getReceiptNumber)).orElse(null))
.createdAt(initiation.getCreatedAt())
.updatedAt(initiation.getUpdatedAt())
.build();
});
}
private Mono<PaymentInitiation> updateStatus(PaymentInitiation initiation, String status) {
initiation.setStatus(status);
initiation.setUpdatedAt(LocalDateTime.now());
return initiationRepository.save(initiation);
/** PENDING initiations older than the cutoff, for the reconciliation job. */
public Flux<PaymentInitiation> findPendingOlderThan(LocalDateTime cutoff) {
return blocking(() -> store.findPendingOlderThan(cutoff)).flatMapMany(Flux::fromIterable);
}
/**
* Upserts the consolidated TRANSACTIONS row for an initiation that reached a
* terminal state. Keyed by initiation_id (UNIQUE) so it can never duplicate;
* a later, richer resolution (e.g. a callback after a query) updates the row.
* Runs one blocking unit of work off the event loop. boundedElastic is the
* scheduler Reactor sizes for exactly this — JDBC calls parked on I/O.
*/
private Mono<Transaction> recordTransaction(PaymentInitiation initiation, PaymentResponse response,
String resultCode, String resultDesc, String receiptNumber,
String transactionDate, String resolvedBy) {
return transactionRepository.findByInitiationId(initiation.getId())
.flatMap(existing -> {
existing.setStatus(initiation.getStatus());
if (resultCode != null) {
existing.setResultCode(resultCode);
}
if (resultDesc != null) {
existing.setResultDesc(resultDesc);
}
if (receiptNumber != null) {
existing.setReceiptNumber(receiptNumber);
}
if (transactionDate != null) {
existing.setTransactionDate(transactionDate);
}
existing.setResolvedBy(resolvedBy);
existing.setUpdatedAt(LocalDateTime.now());
return transactionRepository.save(existing);
})
.switchIfEmpty(Mono.defer(() -> transactionRepository.save(Transaction.builder()
.initiationId(initiation.getId())
.provider(initiation.getProvider())
.providerReference(response != null ? response.getProviderReference() : null)
.secondaryReference(response != null ? response.getSecondaryReference() : null)
.phoneNumber(initiation.getPhoneNumber())
.amount(initiation.getAmount())
.accountReference(initiation.getAccountReference())
.status(initiation.getStatus())
.resultCode(resultCode)
.resultDesc(resultDesc)
.receiptNumber(receiptNumber)
.transactionDate(transactionDate)
.resolvedBy(resolvedBy)
.createdAt(LocalDateTime.now())
.build())
.onErrorResume(DuplicateKeyException.class,
e -> transactionRepository.findByInitiationId(initiation.getId()))))
.doOnNext(tx -> log.info("[{}] transaction {} recorded for initiation {} — status {} (via {})",
initiation.getProvider(), tx.getId(), initiation.getId(), tx.getStatus(), resolvedBy));
}
private String truncate(String value) {
return value == null || value.length() <= 255 ? value : value.substring(0, 255);
private <T> Mono<T> blocking(Callable<T> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -0,0 +1,508 @@
package com.test.payment.service;
import com.test.payment.dto.CallbackAckDto;
import com.test.payment.dto.PaymentRequest;
import com.test.payment.dto.PaymentResultDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.models.AirtelPaymentCallback;
import com.test.payment.models.AirtelPaymentResponse;
import com.test.payment.models.MpesaPaymentCallback;
import com.test.payment.models.MpesaPaymentResponse;
import com.test.payment.models.MtnPaymentCallback;
import com.test.payment.models.MtnPaymentResponse;
import com.test.payment.models.PaymentInitiation;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.Status;
import com.test.payment.models.Transaction;
import com.test.payment.repository.AirtelPaymentCallbackRepository;
import com.test.payment.repository.AirtelPaymentResponseRepository;
import com.test.payment.repository.MpesaPaymentCallbackRepository;
import com.test.payment.repository.MpesaPaymentResponseRepository;
import com.test.payment.repository.MtnPaymentCallbackRepository;
import com.test.payment.repository.MtnPaymentResponseRepository;
import com.test.payment.repository.PaymentInitiationRepository;
import com.test.payment.repository.audit.AirtelRequestRepository;
import com.test.payment.repository.audit.MpesaRequestRepository;
import com.test.payment.repository.audit.MtnRequestRepository;
import com.test.payment.repository.TransactionRepository;
import com.test.payment.service.PaymentLifecycleService.CallbackData;
import com.test.payment.service.PaymentLifecycleService.ProviderResponseData;
import com.test.payment.service.PaymentLifecycleService.QueryOutcome;
import com.test.payment.service.PaymentLifecycleService.StoredResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
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.List;
import java.util.Optional;
/**
* The blocking, transactional half of {@link PaymentLifecycleService}. Every method
* here is one unit of work against JPA; the facade is what puts them on a
* bounded-elastic thread so the WebFlux event loop is never blocked.
*
* <p>It lives in its own bean deliberately: {@code @Transactional} is applied by a
* Spring AOP proxy, which self-invocation from the facade would bypass — the same
* trap the Resilience4j annotations have in the client classes.
*
* <p>Responses and callbacks live in per-operator tables that share no supertype, so
* this class dispatches on {@link com.test.payment.models.Operator} and hands the
* rest of the lifecycle a provider-neutral {@link StoredResponse} view. Note the
* provider equality checks: one table holds every market for that operator, so a
* MPESA_TZ reference must not resolve against a MPESA_KE payment.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentLifecycleStore {
private final PaymentInitiationRepository initiationRepository;
private final TransactionRepository transactionRepository;
private final StatusCatalog statuses;
private final MpesaPaymentResponseRepository mpesaResponses;
private final MpesaPaymentCallbackRepository mpesaCallbacks;
private final AirtelPaymentResponseRepository airtelResponses;
private final AirtelPaymentCallbackRepository airtelCallbacks;
private final MtnPaymentResponseRepository mtnResponses;
private final MtnPaymentCallbackRepository mtnCallbacks;
// the outbound-call audit tables, so a transaction can point at the call it made
private final MpesaRequestRepository mpesaRequests;
private final AirtelRequestRepository airtelRequests;
private final MtnRequestRepository mtnRequests;
/**
* What a status check needs before deciding whether to hit the provider: the
* stored response, whether the payment is still open, and the state to serve
* when it is not.
*/
public record StatusCheckContext(StoredResponse response, boolean pending, TransactionStatusDto currentState) {
}
/** The callback fields the status DTO reads back. */
public record StoredCallback(String resultCode, String resultDesc, String receiptNumber) {
}
/**
* Opens a payment attempt: the initiation and its consolidated transaction row are
* created together. The transaction exists from the request onwards rather than
* appearing only at resolution, so it can be linked to the provider's response and
* callback as each arrives, and a payment is never invisible in TRANSACTIONS.
*/
@Transactional
public PaymentInitiation saveInitiation(PaymentProviderType provider, PaymentRequest request) {
PaymentInitiation initiation = initiationRepository.save(PaymentInitiation.builder()
.Provider(provider)
.PhoneNumber(request.getPhoneNumber())
.Amount(BigDecimal.valueOf(request.getAmount()))
.AccountReference(request.getAccountReference())
.TransactionDesc(request.getTransactionDesc())
.Status(statuses.pending())
.CreatedAt(LocalDateTime.now())
.build());
transactionRepository.save(Transaction.builder()
.Initiation(initiation)
.Provider(provider)
.PhoneNumber(initiation.getPhoneNumber())
.Amount(initiation.getAmount())
.AccountReference(initiation.getAccountReference())
.Status(initiation.getStatus())
.CreatedAt(LocalDateTime.now())
.build());
return initiation;
}
@Transactional
public PaymentResultDto persistResponse(Long initiationId, ProviderResponseData data) {
PaymentInitiation initiation = requireInitiation(initiationId);
Status newStatus = data.accepted() ? statuses.pending() : statuses.failed();
// one response per initiation — an existing row wins, a concurrent insert falls back to it
StoredResponse saved = findResponseByInitiation(initiation.getProvider(), initiationId)
.orElseGet(() -> insertResponse(initiation, data));
PaymentInitiation updated = updateStatus(initiation, newStatus);
// linked either way: a rejection resolves the payment, an acceptance just
// records which response row belongs to it
recordTransaction(updated, saved, null, data.responseDescription(), null, null,
data.accepted() ? null : "REJECTION");
return PaymentResultDto.builder()
.initiationId(updated.getId())
.provider(updated.getProvider())
.status(updated.statusName())
.providerReference(saved.providerReference())
.secondaryReference(saved.secondaryReference())
.responseCode(saved.responseCode())
.responseDescription(saved.responseDescription())
.customerMessage(saved.customerMessage())
.build();
}
@Transactional
public void markFailed(Long initiationId, String reason) {
PaymentInitiation updated = updateStatus(requireInitiation(initiationId), statuses.failed());
recordTransaction(updated, null, null, truncate(reason), null, null, "ERROR");
}
@Transactional
public CallbackAckDto applyCallback(PaymentProviderType provider, CallbackData data, String rawPayload) {
Optional<StoredResponse> match = findResponseByReference(provider, data.providerReference());
if (match.isEmpty()) {
log.warn("[{}] callback for unknown reference {}", provider, data.providerReference());
return CallbackAckDto.accepted("Unknown reference");
}
StoredResponse response = match.get();
if (findCallbackByInitiation(provider, response.initiationId()).isPresent()) {
log.info("[{}] duplicate callback for {} ignored", provider, data.providerReference());
return CallbackAckDto.accepted("Duplicate callback ignored");
}
return saveCallback(response, data, rawPayload);
}
/**
* Loads what a status check needs. Never calls the provider — the HTTP query is
* the facade's job, so no transaction is held open across the network.
*/
@Transactional(readOnly = true)
public StatusCheckContext loadForStatusCheck(PaymentProviderType provider, String providerReference) {
StoredResponse response = findResponseByReference(provider, providerReference)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"No " + provider + " transaction found for reference " + providerReference));
PaymentInitiation initiation = requireInitiation(response.initiationId());
boolean pending = statuses.isPending(initiation.statusName());
return new StatusCheckContext(response, pending, pending ? null : buildStatusDto(initiation, response));
}
/** Applies the outcome of a live provider status query. */
@Transactional
public TransactionStatusDto applyQueryOutcome(Long initiationId, QueryOutcome outcome) {
PaymentInitiation initiation = requireInitiation(initiationId);
StoredResponse response = findResponseByInitiation(initiation.getProvider(), initiationId).orElse(null);
if (statuses.isPending(outcome.newStatus())) {
TransactionStatusDto dto = buildStatusDto(initiation, response);
if (outcome.resultDesc() != null) {
dto.setResultDesc(outcome.resultDesc());
}
return dto;
}
PaymentInitiation updated = updateStatus(initiation, outcome.newStatus());
recordTransaction(updated, response, outcome.resultCode(), outcome.resultDesc(),
outcome.receiptNumber(), null, "QUERY");
TransactionStatusDto dto = buildStatusDto(updated, response);
dto.setResultCode(outcome.resultCode());
dto.setResultDesc(outcome.resultDesc());
if (outcome.receiptNumber() != null) {
dto.setReceiptNumber(outcome.receiptNumber());
}
return dto;
}
/**
* First half of a reconciliation: resolves the provider reference to re-query,
* or terminally fails the initiation when there is nothing to query with.
*
* @return the provider reference, or empty when the initiation was failed outright
*/
@Transactional
public Optional<String> beginReconcile(Long initiationId) {
PaymentInitiation initiation = requireInitiation(initiationId);
Optional<StoredResponse> response = findResponseByInitiation(initiation.getProvider(), initiationId);
if (response.isEmpty()) {
log.warn("[{}] initiation {} never received a provider response — marking FAILED",
initiation.getProvider(), initiationId);
failTerminal(initiation, "No provider response received");
return Optional.empty();
}
if (response.get().providerReference() == null) {
failTerminal(initiation, "No provider reference on response");
return Optional.empty();
}
return Optional.of(response.get().providerReference());
}
@Transactional(readOnly = true)
public List<Transaction> listTransactions(PaymentProviderType provider) {
return provider == null
? transactionRepository.findAllWithAssociations()
: transactionRepository.findByProvider(provider);
}
@Transactional(readOnly = true)
public List<PaymentInitiation> findPendingOlderThan(LocalDateTime cutoff) {
return initiationRepository.findByStatusNameAndCreatedAtBefore(statuses.pending().getName(), cutoff);
}
// --- per-operator dispatch -------------------------------------------------
private StoredResponse insertResponse(PaymentInitiation initiation, ProviderResponseData data) {
PaymentProviderType provider = initiation.getProvider();
try {
return switch (provider.operator()) {
case MPESA -> view(mpesaResponses.saveAndFlush(MpesaPaymentResponse.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference())
.SecondaryReference(data.secondaryReference())
.ResponseCode(data.responseCode())
.ResponseDescription(data.responseDescription())
.CustomerMessage(data.customerMessage())
.CreatedAt(LocalDateTime.now()).build()));
case AIRTEL -> view(airtelResponses.saveAndFlush(AirtelPaymentResponse.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference())
.SecondaryReference(data.secondaryReference())
.ResponseCode(data.responseCode())
.ResponseDescription(data.responseDescription())
.CustomerMessage(data.customerMessage())
.CreatedAt(LocalDateTime.now()).build()));
case MTN -> view(mtnResponses.saveAndFlush(MtnPaymentResponse.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference())
.SecondaryReference(data.secondaryReference())
.ResponseCode(data.responseCode())
.ResponseDescription(data.responseDescription())
.CustomerMessage(data.customerMessage())
.CreatedAt(LocalDateTime.now()).build()));
};
} catch (DataIntegrityViolationException ex) {
return findResponseByInitiation(provider, initiation.getId()).orElseThrow(() -> ex);
}
}
private Optional<StoredResponse> findResponseByInitiation(PaymentProviderType provider, Long initiationId) {
return switch (provider.operator()) {
case MPESA -> mpesaResponses.findByInitiationId(initiationId).map(this::view);
case AIRTEL -> airtelResponses.findByInitiationId(initiationId).map(this::view);
case MTN -> mtnResponses.findByInitiationId(initiationId).map(this::view);
};
}
/** The provider filter matters: one table holds every market for that operator. */
private Optional<StoredResponse> findResponseByReference(PaymentProviderType provider, String providerReference) {
if (providerReference == null) {
return Optional.empty();
}
Optional<StoredResponse> found = switch (provider.operator()) {
case MPESA -> mpesaResponses.findByProviderReference(providerReference).map(this::view);
case AIRTEL -> airtelResponses.findByProviderReference(providerReference).map(this::view);
case MTN -> mtnResponses.findByProviderReference(providerReference).map(this::view);
};
return found.filter(response -> provider == response.provider());
}
private Optional<StoredCallback> findCallbackByInitiation(PaymentProviderType provider, Long initiationId) {
return switch (provider.operator()) {
case MPESA -> mpesaCallbacks.findByInitiationId(initiationId)
.map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber()));
case AIRTEL -> airtelCallbacks.findByInitiationId(initiationId)
.map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber()));
case MTN -> mtnCallbacks.findByInitiationId(initiationId)
.map(c -> new StoredCallback(c.getResultCode(), c.getResultDesc(), c.getReceiptNumber()));
};
}
private CallbackAckDto saveCallback(StoredResponse response, CallbackData data, String rawPayload) {
PaymentProviderType provider = response.provider();
PaymentInitiation initiation = requireInitiation(response.initiationId());
LocalDateTime now = LocalDateTime.now();
try {
switch (provider.operator()) {
case MPESA -> mpesaCallbacks.saveAndFlush(MpesaPaymentCallback.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference()).ResultCode(data.resultCode())
.ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber())
.Amount(data.amount()).PhoneNumber(data.phoneNumber())
.TransactionDate(data.transactionDate()).RawPayload(rawPayload)
.CreatedAt(now).build());
case AIRTEL -> airtelCallbacks.saveAndFlush(AirtelPaymentCallback.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference()).ResultCode(data.resultCode())
.ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber())
.Amount(data.amount()).PhoneNumber(data.phoneNumber())
.TransactionDate(data.transactionDate()).RawPayload(rawPayload)
.CreatedAt(now).build());
case MTN -> mtnCallbacks.saveAndFlush(MtnPaymentCallback.builder()
.Initiation(initiation).Provider(provider)
.ProviderReference(data.providerReference()).ResultCode(data.resultCode())
.ResultDesc(truncate(data.resultDesc())).ReceiptNumber(data.receiptNumber())
.Amount(data.amount()).PhoneNumber(data.phoneNumber())
.TransactionDate(data.transactionDate()).RawPayload(rawPayload)
.CreatedAt(now).build());
}
} catch (DataIntegrityViolationException ex) {
log.info("[{}] concurrent duplicate callback for {} ignored", provider, data.providerReference());
return CallbackAckDto.accepted("Duplicate callback ignored");
}
// A receipt means the money actually moved, which is Paid rather than a bare Success.
Status newStatus = data.success()
? (data.receiptNumber() != null ? statuses.paid() : statuses.success())
: statuses.failed();
PaymentInitiation updated = updateStatus(initiation, newStatus);
Transaction tx = recordTransaction(updated, response, data.resultCode(), data.resultDesc(),
data.receiptNumber(), data.transactionDate(), "CALLBACK");
log.info("[{}] callback processed for initiation {} — status {}",
tx.getProvider(), tx.initiationId(), tx.statusName());
return CallbackAckDto.accepted("Callback processed");
}
private StoredResponse view(MpesaPaymentResponse r) {
return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(),
r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage());
}
private StoredResponse view(AirtelPaymentResponse r) {
return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(),
r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage());
}
private StoredResponse view(MtnPaymentResponse r) {
return new StoredResponse(r.initiationId(), r.getProvider(), r.getProviderReference(),
r.getSecondaryReference(), r.getResponseCode(), r.getResponseDescription(), r.getCustomerMessage());
}
// --- shared lifecycle ------------------------------------------------------
private Transaction failTerminal(PaymentInitiation initiation, String reason) {
PaymentInitiation updated = updateStatus(initiation, statuses.failed());
return recordTransaction(updated, null, null, reason, null, null, "RECONCILIATION");
}
private TransactionStatusDto buildStatusDto(PaymentInitiation initiation, StoredResponse response) {
// result details come from the callback when we have one, otherwise from the
// consolidated transaction row (e.g. when a status query resolved the payment)
Optional<StoredCallback> cb = findCallbackByInitiation(initiation.getProvider(), initiation.getId());
Optional<Transaction> tx = transactionRepository.findByInitiationId(initiation.getId());
return TransactionStatusDto.builder()
.initiationId(initiation.getId())
.provider(initiation.getProvider())
.providerReference(response == null ? null : response.providerReference())
.secondaryReference(response == null ? null : response.secondaryReference())
.status(initiation.statusName())
.phoneNumber(initiation.getPhoneNumber())
.amount(initiation.getAmount())
.accountReference(initiation.getAccountReference())
.resultCode(cb.map(StoredCallback::resultCode)
.or(() -> tx.map(Transaction::getResultCode)).orElse(null))
.resultDesc(cb.map(StoredCallback::resultDesc)
.or(() -> tx.map(Transaction::getResultDesc)).orElse(null))
.receiptNumber(cb.map(StoredCallback::receiptNumber)
.or(() -> tx.map(Transaction::getReceiptNumber)).orElse(null))
.createdAt(initiation.getCreatedAt())
.updatedAt(initiation.getUpdatedAt())
.build();
}
private PaymentInitiation updateStatus(PaymentInitiation initiation, Status status) {
initiation.setStatus(status);
initiation.setUpdatedAt(LocalDateTime.now());
return initiationRepository.save(initiation);
}
/**
* Upserts the consolidated TRANSACTIONS row for an initiation that reached a
* terminal state. Keyed by initiation (UNIQUE) so it can never duplicate;
* a later, richer resolution (e.g. a callback after a query) updates the row.
*/
private Transaction recordTransaction(PaymentInitiation initiation, StoredResponse response,
String resultCode, String resultDesc, String receiptNumber,
String transactionDate, String resolvedBy) {
Transaction tx = transactionRepository.findByInitiationId(initiation.getId())
.orElseGet(() -> Transaction.builder()
.Initiation(initiation)
.Provider(initiation.getProvider())
.PhoneNumber(initiation.getPhoneNumber())
.Amount(initiation.getAmount())
.AccountReference(initiation.getAccountReference())
.CreatedAt(LocalDateTime.now())
.build());
if (response != null) {
tx.setProviderReference(response.providerReference());
tx.setSecondaryReference(response.secondaryReference());
}
tx.setStatus(initiation.getStatus());
if (resultCode != null) {
tx.setResultCode(resultCode);
}
if (resultDesc != null) {
tx.setResultDesc(resultDesc);
}
if (receiptNumber != null) {
tx.setReceiptNumber(receiptNumber);
}
if (transactionDate != null) {
tx.setTransactionDate(transactionDate);
}
if (resolvedBy != null) {
tx.setResolvedBy(resolvedBy);
}
if (tx.getId() != null) {
tx.setUpdatedAt(LocalDateTime.now());
}
attachRequest(tx, initiation.getProvider(), initiation.getId());
attachCallback(tx, initiation.getProvider(), initiation.getId());
Transaction saved = transactionRepository.save(tx);
log.info("[{}] transaction {} recorded for initiation {} — status {} (via {})",
initiation.getProvider(), saved.getId(), initiation.getId(), saved.statusName(), resolvedBy);
return saved;
}
/**
* Links the call that started this payment — the earliest audit request for the
* initiation, since later rows are status queries. Best effort: audit rows are
* written asynchronously, so on the rare occasion the row has not landed yet the
* link is simply picked up by the next update.
*/
private void attachRequest(Transaction tx, PaymentProviderType provider, Long initiationId) {
String reference = String.valueOf(initiationId);
switch (provider.operator()) {
case MPESA -> earliest(mpesaRequests.findByInitiationId(reference)).ifPresent(tx::setMpesaRequest);
case AIRTEL -> earliest(airtelRequests.findByInitiationId(reference)).ifPresent(tx::setAirtelRequest);
case MTN -> earliest(mtnRequests.findByInitiationId(reference)).ifPresent(tx::setMtnRequest);
}
}
private <T> Optional<T> earliest(List<T> rows) {
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
/** Same for the callback, which only exists once the operator has reported back. */
private void attachCallback(Transaction tx, PaymentProviderType provider, Long initiationId) {
switch (provider.operator()) {
case MPESA -> mpesaCallbacks.findByInitiationId(initiationId).ifPresent(tx::setMpesaCallback);
case AIRTEL -> airtelCallbacks.findByInitiationId(initiationId).ifPresent(tx::setAirtelCallback);
case MTN -> mtnCallbacks.findByInitiationId(initiationId).ifPresent(tx::setMtnCallback);
}
}
private PaymentInitiation requireInitiation(Long initiationId) {
return initiationRepository.findById(initiationId)
.orElseThrow(() -> new IllegalStateException("Initiation " + initiationId + " no longer exists"));
}
private String truncate(String value) {
return value == null || value.length() <= 255 ? value : value.substring(0, 255);
}
}

View File

@@ -2,166 +2,51 @@ 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 reactor.core.scheduler.Schedulers;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.concurrent.Callable;
/**
* 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.
*
* <p>Reactive facade over {@link PaymentLimitStore}, which holds the blocking JPA work.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentLimitService {
private final ProviderLimitRepository limitRepository;
private final PaymentInitiationRepository initiationRepository;
private final PaymentLimitStore store;
/**
* 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();
public Mono<Void> enforce(PaymentProviderType provider, PaymentRequest request) {
return blocking(() -> {
store.enforce(provider, request);
return true;
}).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());
public Flux<ProviderLimit> list(PaymentProviderType provider) {
return blocking(() -> store.list(provider)).flatMapMany(Flux::fromIterable);
}
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()));
return blocking(() -> store.upsert(dto));
}
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())));
}
private <T> Mono<T> blocking(Callable<T> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic());
}
/**
* 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

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

View File

@@ -3,6 +3,7 @@ package com.test.payment.service;
import com.test.payment.dto.PaymentRequest;
import com.test.payment.dto.PaymentResultDto;
import com.test.payment.dto.TransactionStatusDto;
import com.test.payment.models.PaymentProviderType;
import reactor.core.publisher.Mono;
/**
@@ -12,7 +13,8 @@ import reactor.core.publisher.Mono;
*/
public interface PaymentProviderService {
String provider();
/** The market-qualified provider this service collects for, e.g. AIRTEL_KE. */
PaymentProviderType provider();
Mono<PaymentResultDto> initiatePayment(PaymentRequest request);

View File

@@ -0,0 +1,350 @@
package com.test.payment.service;
import com.test.payment.models.Operator;
import com.test.payment.models.PaymentProviderType;
import com.test.payment.models.audit.AirtelCallbackResponse;
import com.test.payment.models.audit.AirtelRequest;
import com.test.payment.models.audit.AirtelResponse;
import com.test.payment.models.audit.MpesaCallbackResponse;
import com.test.payment.models.audit.MpesaRequest;
import com.test.payment.models.audit.MpesaResponse;
import com.test.payment.models.audit.MtnCallbackResponse;
import com.test.payment.models.audit.MtnRequest;
import com.test.payment.models.audit.MtnResponse;
import com.test.payment.repository.audit.AirtelCallbackResponseRepository;
import com.test.payment.repository.audit.AirtelRequestRepository;
import com.test.payment.repository.audit.AirtelResponseRepository;
import com.test.payment.repository.audit.MpesaCallbackResponseRepository;
import com.test.payment.repository.audit.MpesaRequestRepository;
import com.test.payment.repository.audit.MpesaResponseRepository;
import com.test.payment.repository.audit.MtnCallbackResponseRepository;
import com.test.payment.repository.audit.MtnRequestRepository;
import com.test.payment.repository.audit.MtnResponseRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
/**
* Records every call we make to an operator — request body, response body, timing,
* failures — plus the callbacks they send back, into that operator's own tables
* (mpesa_requests / mpesa_responses / mpesa_callback_responses, and the Airtel and
* MTN equivalents). The nine entities are standalone with no shared supertype, so
* this class dispatches on {@link Operator} rather than on a common base class.
*
* <p>Everything here runs on the audit executor, never on the caller's thread: a
* payment is never delayed by, and never fails because of, its audit trail. Ordering
* within one call is preserved by chaining onto the request's own future rather than
* by blocking on it — a {@link Handle} carries only the eventual row id.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ProviderCallAudit {
/** Anything that looks like a credential is masked before it reaches the table. */
private static final Pattern SECRETS = Pattern.compile(
"(?i)(\"(?:password|passkey|pass_key|api[_-]?key|apikey|secret|client_secret|authorization|access_token)\"\\s*:\\s*)\"[^\"]*\"");
private final ExecutorService auditExecutor;
private final ObjectMapper objectMapper;
private final MpesaRequestRepository mpesaRequests;
private final MpesaResponseRepository mpesaResponses;
private final MpesaCallbackResponseRepository mpesaCallbacks;
private final AirtelRequestRepository airtelRequests;
private final AirtelResponseRepository airtelResponses;
private final AirtelCallbackResponseRepository airtelCallbacks;
private final MtnRequestRepository mtnRequests;
private final MtnResponseRepository mtnResponses;
private final MtnCallbackResponseRepository mtnCallbacks;
/**
* A call in flight. Holds the future id of the persisted request row so the
* response can be attached to it without anyone blocking. The id is null when
* the request row could not be written.
*/
public record Handle(PaymentProviderType provider, CompletableFuture<Long> requestId, long startedAt) {
public static Handle none() {
return new Handle(null, CompletableFuture.completedFuture(null), 0L);
}
}
/** The parsed fields of a callback worth querying on, alongside the raw payload. */
public record CallbackAudit(String providerReference, String resultCode, String resultDesc, String receiptNumber,
BigDecimal amount, String phoneNumber, String transactionDate) {
}
/** Records an outbound call. Returns immediately; the row is written in the background. */
public Handle begin(PaymentProviderType provider, String operation, String initiationId,
String httpMethod, String url, Object requestBody) {
long startedAt = System.nanoTime();
String body = redact(toJson(requestBody));
String trimmedUrl = truncate(url, 512);
CompletableFuture<Long> requestId = CompletableFuture.supplyAsync(
() -> saveRequest(provider, operation, initiationId, httpMethod, trimmedUrl, body),
auditExecutor)
.exceptionally(ex -> {
log.warn("[{}] could not record {} request audit: {}", provider, operation, ex.toString());
return null;
});
return new Handle(provider, requestId, startedAt);
}
/** Records the operator's answer against the call opened by {@link #begin}. */
public void complete(Handle handle, Integer httpStatus, Object responseBody, Throwable error) {
if (handle == null || handle.provider() == null) {
return;
}
long durationMs = (System.nanoTime() - handle.startedAt()) / 1_000_000;
String body = redact(toJson(responseBody));
String failure = error == null ? null : truncate(error.toString(), 512);
handle.requestId().thenAcceptAsync(requestId -> {
if (requestId == null) {
return;
}
saveResponse(handle.provider(), requestId, httpStatus, body, failure, durationMs);
}, auditExecutor).exceptionally(ex -> {
log.warn("[{}] could not record response audit: {}", handle.provider(), ex.toString());
return null;
});
}
/**
* Stamps the operator's reference onto the call that produced it, which is what
* later lets an inbound callback be matched back to its originating request.
*/
public void linkReference(Handle handle, String providerReference) {
if (handle == null || handle.provider() == null || providerReference == null) {
return;
}
handle.requestId().thenAcceptAsync(requestId -> {
if (requestId == null) {
return;
}
stampReference(handle.provider(), requestId, providerReference);
}, auditExecutor).exceptionally(ex -> {
log.warn("[{}] could not link provider reference {}: {}",
handle.provider(), providerReference, ex.toString());
return null;
});
}
/** Records an inbound callback, matched to its originating request where possible. */
public void recordCallback(PaymentProviderType provider, CallbackAudit data, String rawPayload) {
String payload = redact(rawPayload);
CompletableFuture.runAsync(() -> saveCallback(provider, data, payload), auditExecutor)
.exceptionally(ex -> {
log.warn("[{}] could not record callback audit: {}", provider, ex.toString());
return null;
});
}
// --- per-operator dispatch -------------------------------------------------
// Explicit rather than polymorphic: the nine tables are independent by design, and
// a new operator should not compile until all three of its tables are wired up.
private Long saveRequest(PaymentProviderType provider, String operation, String initiationId,
String httpMethod, String url, String body) {
return switch (provider.operator()) {
case MPESA -> {
MpesaRequest entity = new MpesaRequest();
entity.setProvider(provider);
entity.setOperation(operation);
entity.setInitiationId(initiationId);
entity.setHttpMethod(httpMethod);
entity.setUrl(url);
entity.setRequestBody(body);
entity.setCreatedAt(Instant.now());
yield mpesaRequests.save(entity).getId();
}
case AIRTEL -> {
AirtelRequest entity = new AirtelRequest();
entity.setProvider(provider);
entity.setOperation(operation);
entity.setInitiationId(initiationId);
entity.setHttpMethod(httpMethod);
entity.setUrl(url);
entity.setRequestBody(body);
entity.setCreatedAt(Instant.now());
yield airtelRequests.save(entity).getId();
}
case MTN -> {
MtnRequest entity = new MtnRequest();
entity.setProvider(provider);
entity.setOperation(operation);
entity.setInitiationId(initiationId);
entity.setHttpMethod(httpMethod);
entity.setUrl(url);
entity.setRequestBody(body);
entity.setCreatedAt(Instant.now());
yield mtnRequests.save(entity).getId();
}
};
}
/**
* getReferenceById rather than findById: this only needs the foreign key, so
* there is no reason to read the request row back out again.
*/
private void saveResponse(PaymentProviderType provider, Long requestId, Integer httpStatus,
String body, String failure, long durationMs) {
switch (provider.operator()) {
case MPESA -> {
MpesaResponse entity = new MpesaResponse();
entity.setRequest(mpesaRequests.getReferenceById(requestId));
entity.setHttpStatus(httpStatus);
entity.setResponseBody(body);
entity.setError(failure);
entity.setDurationMs(durationMs);
entity.setCreatedAt(Instant.now());
mpesaResponses.save(entity);
}
case AIRTEL -> {
AirtelResponse entity = new AirtelResponse();
entity.setRequest(airtelRequests.getReferenceById(requestId));
entity.setHttpStatus(httpStatus);
entity.setResponseBody(body);
entity.setError(failure);
entity.setDurationMs(durationMs);
entity.setCreatedAt(Instant.now());
airtelResponses.save(entity);
}
case MTN -> {
MtnResponse entity = new MtnResponse();
entity.setRequest(mtnRequests.getReferenceById(requestId));
entity.setHttpStatus(httpStatus);
entity.setResponseBody(body);
entity.setError(failure);
entity.setDurationMs(durationMs);
entity.setCreatedAt(Instant.now());
mtnResponses.save(entity);
}
}
}
private void stampReference(PaymentProviderType provider, Long requestId, String providerReference) {
switch (provider.operator()) {
case MPESA -> mpesaRequests.findById(requestId).ifPresent(request -> {
request.setProviderReference(providerReference);
mpesaRequests.save(request);
});
case AIRTEL -> airtelRequests.findById(requestId).ifPresent(request -> {
request.setProviderReference(providerReference);
airtelRequests.save(request);
});
case MTN -> mtnRequests.findById(requestId).ifPresent(request -> {
request.setProviderReference(providerReference);
mtnRequests.save(request);
});
}
}
private void saveCallback(PaymentProviderType provider, CallbackAudit data, String payload) {
String reference = data.providerReference();
boolean matched;
switch (provider.operator()) {
case MPESA -> {
MpesaRequest request = reference == null ? null
: first(mpesaRequests.findByProviderReference(reference));
MpesaCallbackResponse entity = new MpesaCallbackResponse();
entity.setRequest(request);
entity.setProvider(provider);
entity.setProviderReference(reference);
entity.setResultCode(data.resultCode());
entity.setResultDesc(truncate(data.resultDesc(), 255));
entity.setReceiptNumber(data.receiptNumber());
entity.setAmount(data.amount());
entity.setPhoneNumber(data.phoneNumber());
entity.setTransactionDate(data.transactionDate());
entity.setRawPayload(payload);
entity.setMatched(request != null);
entity.setCreatedAt(Instant.now());
mpesaCallbacks.save(entity);
matched = request != null;
}
case AIRTEL -> {
AirtelRequest request = reference == null ? null
: first(airtelRequests.findByProviderReference(reference));
AirtelCallbackResponse entity = new AirtelCallbackResponse();
entity.setRequest(request);
entity.setProvider(provider);
entity.setProviderReference(reference);
entity.setResultCode(data.resultCode());
entity.setResultDesc(truncate(data.resultDesc(), 255));
entity.setReceiptNumber(data.receiptNumber());
entity.setAmount(data.amount());
entity.setPhoneNumber(data.phoneNumber());
entity.setTransactionDate(data.transactionDate());
entity.setRawPayload(payload);
entity.setMatched(request != null);
entity.setCreatedAt(Instant.now());
airtelCallbacks.save(entity);
matched = request != null;
}
case MTN -> {
MtnRequest request = reference == null ? null
: first(mtnRequests.findByProviderReference(reference));
MtnCallbackResponse entity = new MtnCallbackResponse();
entity.setRequest(request);
entity.setProvider(provider);
entity.setProviderReference(reference);
entity.setResultCode(data.resultCode());
entity.setResultDesc(truncate(data.resultDesc(), 255));
entity.setReceiptNumber(data.receiptNumber());
entity.setAmount(data.amount());
entity.setPhoneNumber(data.phoneNumber());
entity.setTransactionDate(data.transactionDate());
entity.setRawPayload(payload);
entity.setMatched(request != null);
entity.setCreatedAt(Instant.now());
mtnCallbacks.save(entity);
matched = request != null;
}
default -> matched = false;
}
if (!matched) {
log.warn("[{}] callback for {} could not be matched to an outbound request", provider, reference);
}
}
// --- helpers ---------------------------------------------------------------
/** Head of an already-ordered result, or null when there is none. */
private <T> T first(java.util.List<T> rows) {
return rows.isEmpty() ? null : rows.get(0);
}
private String toJson(Object value) {
if (value == null) {
return null;
}
if (value instanceof String string) {
return string;
}
try {
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
return "<unserializable: " + value.getClass().getSimpleName() + ">";
}
}
private String redact(String json) {
return json == null ? null : SECRETS.matcher(json).replaceAll("$1\"***\"");
}
private String truncate(String value, int max) {
return value == null || value.length() <= max ? value : value.substring(0, max);
}
}

View File

@@ -0,0 +1,50 @@
package com.test.payment.service;
import com.test.payment.models.Country;
import com.test.payment.models.Operator;
import com.test.payment.models.PaymentProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
import java.util.Map;
/**
* Resolves which market each operator is configured for — {@code <operator>.country}
* in application.yml — into the market-qualified {@link PaymentProviderType} that
* payments are recorded against.
*
* <p>A bad or unsupported country fails here, at the first call, rather than as an
* opaque error from the provider's API.
*/
@Component
@RequiredArgsConstructor
public class ProviderMarkets {
private static final Map<Operator, Country> DEFAULT_MARKET = new EnumMap<>(Map.of(
Operator.MPESA, Country.KE,
Operator.AIRTEL, Country.KE,
Operator.MTN, Country.UG));
private final Environment environment;
private final Map<Operator, PaymentProviderType> resolved = new EnumMap<>(Operator.class);
public PaymentProviderType resolve(Operator operator) {
return resolved.computeIfAbsent(operator, this::read);
}
/** Convenience for the many call sites that only need the stored provider name. */
public String providerName(Operator operator) {
return resolve(operator).name();
}
private PaymentProviderType read(Operator operator) {
String key = operator.name().toLowerCase() + ".country";
String configured = environment.getProperty(key, DEFAULT_MARKET.get(operator).name());
Country country = Country.of(configured).orElseThrow(() -> new IllegalStateException(
"%s is '%s', which is not a known country — expected one of %s"
.formatted(key, configured, operator.countries())));
return PaymentProviderType.require(operator, country);
}
}

View File

@@ -0,0 +1,101 @@
package com.test.payment.service;
import com.test.payment.models.Status;
import com.test.payment.repository.StatusRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* The lifecycle states, read from the STATUSES table rather than hard-coded as an
* enum — a state can be re-described, or new ones added, without a redeploy.
*
* <p>This is the only place that names the four states the code actually branches
* on. Everything else asks for one through {@link #pending()}, {@link #paid()},
* {@link #success()} or {@link #failed()} and gets whichever row is currently in the
* table; the rows are memoised, since a status transition should not cost a query.
*/
@Service
@RequiredArgsConstructor
public class StatusCatalog {
/** A state the seeder should ensure exists. */
public record StatusDefinition(String name, String description) {
}
private static final String PAID = "Paid";
private static final String SUCCESS = "Success";
private static final String PENDING = "Pending";
private static final String FAILED = "Failed";
/**
* What {@code statuses} is seeded with when a state is missing. The table is the
* source of truth from then on — edit a description there, not here.
*/
private static final List<StatusDefinition> DEFAULTS = List.of(
new StatusDefinition(PAID, "Payment settled and confirmed — a provider receipt exists"),
new StatusDefinition(SUCCESS, "Provider reported the collection succeeded"),
new StatusDefinition(PENDING, "Pushed to the payer, awaiting confirmation or the provider callback"),
new StatusDefinition(FAILED, "Rejected by the provider, declined by the payer, or timed out"));
private final StatusRepository statusRepository;
private final Map<String, Status> byName = new ConcurrentHashMap<>();
public List<StatusDefinition> defaults() {
return DEFAULTS;
}
/** Payment settled and confirmed — a provider receipt exists. */
public Status paid() {
return require(PAID);
}
/** Provider reported the collection succeeded. */
public Status success() {
return require(SUCCESS);
}
/** Pushed to the payer, awaiting their confirmation or the provider's callback. */
public Status pending() {
return require(PENDING);
}
/** Provider rejected the request, the payer declined, or the push timed out. */
public Status failed() {
return require(FAILED);
}
/** True when the payment is still open — decides whether to re-query the provider. */
public boolean isPending(String statusName) {
return PENDING.equals(statusName);
}
public boolean isPending(Status status) {
return status != null && isPending(status.getName());
}
/** The name the FAILED state is stored under, for the limit queries that exclude it. */
public String failedName() {
return FAILED;
}
/**
* Looks a state up by the name stored in the table.
*
* @throws IllegalStateException when it was never seeded — a configuration error,
* not something a caller can recover from
*/
public Status require(String name) {
return byName.computeIfAbsent(name, key -> statusRepository.findByName(key)
.orElseThrow(() -> new IllegalStateException(
"Status '" + key + "' is missing from the statuses table — was the seeder skipped?")));
}
/** Drops the memoised rows; used by the seeder after it inserts. */
public void invalidate() {
byName.clear();
}
}

View File

@@ -1,5 +1,6 @@
package com.test.payment.service;
import com.test.payment.models.Operator;
import com.test.payment.models.ProviderToken;
import com.test.payment.repository.ProviderTokenRepository;
import lombok.RequiredArgsConstructor;
@@ -8,9 +9,11 @@ import org.springframework.core.env.Environment;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.Callable;
import java.util.function.Supplier;
/**
@@ -29,10 +32,9 @@ public class TokenCacheService {
private final ReactiveStringRedisTemplate redisTemplate;
private final Environment environment;
public record FetchedToken(String accessToken, long expiresInSeconds) {
}
public record FetchedToken(String accessToken, long expiresInSeconds) { }
public Mono<String> getToken(String provider, Supplier<Mono<FetchedToken>> fetcher) {
public Mono<String> getToken(Operator provider, Supplier<Mono<FetchedToken>> fetcher) {
return fromRedis(provider)
.switchIfEmpty(Mono.defer(() -> fromDatabase(provider)))
.switchIfEmpty(Mono.defer(() -> fetchAndStore(provider, fetcher)));
@@ -41,17 +43,21 @@ public class TokenCacheService {
/**
* Drops the cached token from Redis and the database (used on 401 from the provider).
*/
public Mono<Void> evictToken(String provider) {
public Mono<Void> evictToken(Operator provider) {
return redisTemplate.opsForValue().delete(redisKey(provider))
.timeout(REDIS_TIMEOUT)
.onErrorResume(e -> {
log.warn("[{}] Redis unavailable while evicting token: {}", provider, e.toString());
return Mono.just(false);
})
.then(tokenRepository.deleteByProvider(provider));
.then(blocking(() -> {
tokenRepository.deleteByProvider(provider);
return true;
}))
.then();
}
private Mono<String> fromRedis(String provider) {
private Mono<String> fromRedis(Operator provider) {
return redisTemplate.opsForValue().get(redisKey(provider))
.timeout(REDIS_TIMEOUT)
.doOnNext(t -> log.debug("[{}] token served from Redis", provider))
@@ -61,9 +67,10 @@ public class TokenCacheService {
});
}
private Mono<String> fromDatabase(String provider) {
private Mono<String> fromDatabase(Operator provider) {
int buffer = expiryBufferSeconds();
return tokenRepository.findFirstByProviderAndExpiresAtAfterOrderByIdDesc(provider, LocalDateTime.now().plusSeconds(buffer))
return blocking(() -> tokenRepository.findUsable(provider, LocalDateTime.now().plusSeconds(buffer)))
.flatMap(tokens -> tokens.isEmpty() ? Mono.empty() : Mono.just(tokens.get(0)))
.flatMap(token -> {
long ttl = Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds() - buffer;
log.debug("[{}] token served from database", provider);
@@ -71,23 +78,23 @@ public class TokenCacheService {
});
}
private Mono<String> fetchAndStore(String provider, Supplier<Mono<FetchedToken>> fetcher) {
private Mono<String> fetchAndStore(Operator provider, Supplier<Mono<FetchedToken>> fetcher) {
return fetcher.get()
.flatMap(fetched -> {
ProviderToken token = ProviderToken.builder()
.provider(provider)
.accessToken(fetched.accessToken())
.expiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds()))
.createdAt(LocalDateTime.now())
.Provider(provider)
.AccessToken(fetched.accessToken())
.ExpiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds()))
.CreatedAt(LocalDateTime.now())
.build();
log.info("[{}] fetched new access token (expires in {}s)", provider, fetched.expiresInSeconds());
return tokenRepository.save(token)
return blocking(() -> tokenRepository.save(token))
.then(cacheInRedis(provider, fetched.accessToken(), fetched.expiresInSeconds() - expiryBufferSeconds()))
.thenReturn(fetched.accessToken());
});
}
private Mono<Void> cacheInRedis(String provider, String token, long ttlSeconds) {
private Mono<Void> cacheInRedis(Operator provider, String token, long ttlSeconds) {
if (ttlSeconds <= 0) {
return Mono.empty();
}
@@ -101,11 +108,16 @@ public class TokenCacheService {
.then();
}
private String redisKey(String provider) {
return provider.toLowerCase() + ":access_token";
private String redisKey(Operator provider) {
return provider.name().toLowerCase() + ":access_token";
}
private int expiryBufferSeconds() {
return environment.getProperty("payments.token-expiry-buffer-seconds", Integer.class, 60);
}
/** The token table is JPA now, so its reads and writes go off the event loop. */
private <T> Mono<T> blocking(Callable<T> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -47,10 +47,16 @@ resilience4j:
mtnCircuitBreaker: *provider-circuit-breaker
spring:
r2dbc:
url: r2dbc:h2:mem:///mpesa_db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
datasource:
url: jdbc:postgresql://localhost:5432/payments
username: myapp
password: your_password
jpa:
hibernate:
ddl-auto: update
# WebFlux has no OSIV filter anyway; keeping it off makes lazy-loading
# outside a transaction fail loudly instead of on a random worker thread.
open-in-view: false
main:
web-application-type: reactive
data:
@@ -62,7 +68,7 @@ spring:
logging:
level:
org.springframework.data.r2dbc: DEBUG
org.hibernate.SQL: DEBUG
springdoc:
swagger-ui:
@@ -82,6 +88,8 @@ payments:
fixed-delay: 60s
mpesa:
# market this operator is wired for -> provider MPESA_KE
country: KE
base-url: https://sandbox.safaricom.co.ke
consumer-key: k6e7LtBNeVX7V8MPqB7P83FsZio8cRZD
consumer-secret: cGwiWzhDGopC3dho
@@ -90,13 +98,16 @@ mpesa:
callback-url: https://mydomain.com/api/mpesa/callback
airtel:
# KE -> AIRTEL_KE, UG -> AIRTEL_UG, ...
country: KE
base-url: https://openapiuat.airtel.africa
client-id: REPLACE_WITH_AIRTEL_CLIENT_ID
client-secret: REPLACE_WITH_AIRTEL_CLIENT_SECRET
country: KE
currency: KES
mtn:
# UG -> MTN_UG, GH -> MTN_GH, ...
country: UG
base-url: https://sandbox.momodeveloper.mtn.com
subscription-key: REPLACE_WITH_MTN_SUBSCRIPTION_KEY
api-user: REPLACE_WITH_MTN_API_USER