Updates
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
117
src/main/java/com/test/payment/configurations/DataSeeder.java
Normal file
117
src/main/java/com/test/payment/configurations/DataSeeder.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
59
src/main/java/com/test/payment/models/Country.java
Normal file
59
src/main/java/com/test/payment/models/Country.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
43
src/main/java/com/test/payment/models/Operator.java
Normal file
43
src/main/java/com/test/payment/models/Operator.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
49
src/main/java/com/test/payment/models/Status.java
Normal file
49
src/main/java/com/test/payment/models/Status.java
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.test.payment.models;
|
||||
|
||||
public enum TransactionStatus {
|
||||
PENDING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
68
src/main/java/com/test/payment/models/audit/MtnRequest.java
Normal file
68
src/main/java/com/test/payment/models/audit/MtnRequest.java
Normal 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;
|
||||
}
|
||||
52
src/main/java/com/test/payment/models/audit/MtnResponse.java
Normal file
52
src/main/java/com/test/payment/models/audit/MtnResponse.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
181
src/main/java/com/test/payment/service/PaymentLimitStore.java
Normal file
181
src/main/java/com/test/payment/service/PaymentLimitStore.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
350
src/main/java/com/test/payment/service/ProviderCallAudit.java
Normal file
350
src/main/java/com/test/payment/service/ProviderCallAudit.java
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/main/java/com/test/payment/service/ProviderMarkets.java
Normal file
50
src/main/java/com/test/payment/service/ProviderMarkets.java
Normal 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);
|
||||
}
|
||||
}
|
||||
101
src/main/java/com/test/payment/service/StatusCatalog.java
Normal file
101
src/main/java/com/test/payment/service/StatusCatalog.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user