purge paystack

This commit is contained in:
2026-06-15 11:38:04 +03:00
parent 8f7cec33ef
commit 6a98e8984e
26 changed files with 85 additions and 730 deletions
-6
View File
@@ -16,9 +16,3 @@ FARE_PER_KM_RATE=50.00
FARE_MINIMUM_FARE=150.00
FARE_DEFAULT_SURGE_MULTIPLIER=1.0
FARE_CURRENCY=KES
PAYSTACK_SECRET_KEY=
PAYSTACK_PUBLIC_KEY=
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
PAYSTACK_WEBHOOK_SECRET=
PAYSTACK_API_BASE_URL=https://api.paystack.co
-5
View File
@@ -17,11 +17,6 @@ No PostgreSQL container or Render database is created. Use your Neon database co
4. If Render assigns different service URLs than the defaults in `render.yaml`, update:
- `cymelle-api` -> `APP_CORS_ALLOWED_ORIGINS` to the frontend URL.
- `cymelle-web` -> `VITE_API_BASE_URL` to the API URL plus `/api`.
5. Configure Paystack on `cymelle-api`:
- `PAYSTACK_SECRET_KEY`: your Paystack secret key.
- `PAYSTACK_PUBLIC_KEY`: your Paystack public key, kept for operational visibility.
- `PAYSTACK_CALLBACK_URL`: frontend callback URL, for example `https://cymelle-web.onrender.com/payments/paystack/callback`.
- `PAYSTACK_WEBHOOK_SECRET`: optional override for webhook HMAC verification; when omitted, the secret key is used.
## Local Docker builds
+25 -44
View File
@@ -1,10 +1,10 @@
# Cymelle
Cymelle is a commerce, delivery, and ride operations application. Customers can browse products, place orders, request delivery rides, and pay through Paystack. Admins manage products, inventory, orders, users, and rides. Drivers accept and complete delivery rides.
Cymelle is a commerce, delivery, and ride operations application. Customers can browse products, place orders, request delivery rides, and complete a simulated checkout. Admins manage products, inventory, orders, users, and rides. Drivers accept and complete delivery rides.
## Architecture
The current system is a three-tier application: a React frontend, a Spring Boot API, and PostgreSQL. Paystack is the external payment provider.
The current system is a three-tier application: a React frontend, a Spring Boot API, and PostgreSQL.
```text
+-----------------------------+
@@ -32,13 +32,13 @@ The current system is a three-tier application: a React frontend, a Spring Boot
| Security + Services + JPA |
| Swagger/OpenAPI |
+--------+--------------------+
| |
| JDBC | HTTPS
v v
+----------------+ +------------------+
| PostgreSQL | | Paystack API |
| app data | | payments |
+----------------+ +------------------+
|
| JDBC
v
+----------------+
| PostgreSQL |
| app data |
+----------------+
```
Local Docker Compose runs the same shape with three services:
@@ -106,7 +106,7 @@ Local Docker Compose runs the same shape with three services:
- PostgreSQL 18 Alpine locally
- Render Docker web services for deployment
- Neon PostgreSQL for deployed database
- Paystack payment integration
- Simulated checkout for local and deployed testing
## Domain Model
@@ -216,7 +216,7 @@ Backend calculates product subtotal
Quote returns productSubtotal, rideFare, grandTotal, currency, expiresAt
```
The quote endpoint is useful before Paystack initialization so the customer can see the final total.
The quote endpoint is useful before checkout so the customer can see the final total.
### Order Placement
@@ -242,10 +242,10 @@ Order and OrderItems are created as PENDING
Order items store `productName`, `unitPrice`, and `lineTotal`, so historical orders keep their checkout-time product snapshot even if the product changes later.
### Payment With Paystack
### Simulated Checkout
```text
Customer initializes Paystack checkout
Customer starts checkout
|
v
Backend calculates quote and creates pending order
@@ -254,22 +254,13 @@ Backend calculates quote and creates pending order
Payment row is created with unique reference
|
v
Backend initializes Paystack transaction
Backend marks the local simulated payment as PAID
|
v
Frontend redirects user to Paystack authorization URL
|
v
Paystack callback/webhook triggers backend verification
|
v
Backend verifies reference, amount, and currency with Paystack
|
+--> success -> Payment becomes PAID
+--> failure -> Payment becomes FAILED / ABANDONED / REFUNDED
Frontend clears the cart and sends the customer to orders
```
The backend verifies payment server-to-server. The frontend callback is not treated as proof of payment by itself.
No external payment provider is required for local or deployed testing.
### Order Status
@@ -313,8 +304,7 @@ All backend endpoints are under `/api`.
| Products | `GET /products`, `GET /products/{id}`, `POST /products`, `PUT /products/{id}`, `DELETE /products/{id}` |
| Inventory | `GET /inventory`, `GET /inventory/low-stock` |
| Orders | `POST /orders`, `GET /orders`, `GET /orders/{id}`, `DELETE /orders/{id}`, `PATCH /orders/{id}/status` |
| Checkout | `POST /checkout/quote` |
| Paystack | `POST /payments/paystack/initialize`, `POST /payments/paystack/verify/{reference}`, `POST /payments/paystack/webhook` |
| Checkout | `POST /checkout/quote`, `POST /checkout/simulate` |
| Rides | `GET /rides`, `POST /rides`, `POST /rides/{id}/accept`, `POST /rides/{id}/complete`, `DELETE /rides/{id}`, `PATCH /rides/{id}/status` |
| Users | `GET /users`, `GET /users/customers`, `GET /users/drivers`, `POST /users`, `PUT /users/{id}`, `DELETE /users/{id}` |
| Fare | `GET /fare/calculate` |
@@ -346,7 +336,6 @@ Seeded products include accessories and office equipment such as wireless mice,
### Prerequisites
- Docker and Docker Compose
- Paystack test keys if you want to test checkout payments
### Run Everything With Docker Compose
@@ -364,6 +353,14 @@ Services:
- Postgres: `localhost:5432`
- Default database/user/password: `cymelle` / `cymelle` / `cymelle`
Seeded test credentials are available when `SEED_DATA=true`:
| Role | Username | Password |
| --- | --- | --- |
| Admin | `admin` | `admin123` |
| Driver | `driver` | `driver123` |
| Customer | `customer` | `customer123` |
Useful commands:
```sh
@@ -399,24 +396,8 @@ FARE_PER_KM_RATE=50.00
FARE_MINIMUM_FARE=150.00
FARE_DEFAULT_SURGE_MULTIPLIER=1.0
FARE_CURRENCY=KES
PAYSTACK_SECRET_KEY=
PAYSTACK_PUBLIC_KEY=
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
PAYSTACK_WEBHOOK_SECRET=
PAYSTACK_API_BASE_URL=https://api.paystack.co
```
For Paystack checkout, set:
```sh
PAYSTACK_SECRET_KEY=sk_test_...
PAYSTACK_PUBLIC_KEY=pk_test_...
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
```
If `PAYSTACK_WEBHOOK_SECRET` is empty, the backend uses the Paystack secret key for webhook signature verification.
### Run Services Manually
Docker Compose is recommended, but the services can run separately.
+1 -6
View File
@@ -10,7 +10,7 @@ services:
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
healthcheck:
test:
[
@@ -38,11 +38,6 @@ services:
FARE_MINIMUM_FARE: ${FARE_MINIMUM_FARE:-150.00}
FARE_DEFAULT_SURGE_MULTIPLIER: ${FARE_DEFAULT_SURGE_MULTIPLIER:-1.0}
FARE_CURRENCY: ${FARE_CURRENCY:-KES}
PAYSTACK_SECRET_KEY: ${PAYSTACK_SECRET_KEY:-}
PAYSTACK_PUBLIC_KEY: ${PAYSTACK_PUBLIC_KEY:-}
PAYSTACK_CALLBACK_URL: ${PAYSTACK_CALLBACK_URL:-http://localhost:3000/payments/paystack/callback}
PAYSTACK_WEBHOOK_SECRET: ${PAYSTACK_WEBHOOK_SECRET:-}
PAYSTACK_API_BASE_URL: ${PAYSTACK_API_BASE_URL:-https://api.paystack.co}
ports:
- "${API_PORT:-8080}:8080"
volumes:
-6
View File
@@ -10,10 +10,4 @@ FARE_MINIMUM_FARE=150.00
FARE_DEFAULT_SURGE_MULTIPLIER=1.0
FARE_CURRENCY=KES
PAYSTACK_SECRET_KEY=
PAYSTACK_PUBLIC_KEY=
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
PAYSTACK_WEBHOOK_SECRET=
PAYSTACK_API_BASE_URL=https://api.paystack.co
SEED_DATA=true
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=60000
networkTimeout=300000
retries=3
retryBackOffMs=1000
validateDistributionUrl=true
@@ -2,7 +2,6 @@ package com.andwati.orders;
import com.andwati.orders.config.FareProperties;
import com.andwati.orders.config.InventoryProperties;
import com.andwati.orders.config.PaystackProperties;
import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -11,8 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
@SpringBootApplication
@EnableConfigurationProperties({
FareProperties.class,
InventoryProperties.class,
PaystackProperties.class
InventoryProperties.class
})
public class OrdersBackendApplication {
@@ -1,32 +0,0 @@
package com.andwati.orders.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "paystack")
public record PaystackProperties(
String secretKey,
String publicKey,
String callbackUrl,
String webhookSecret,
String apiBaseUrl
) {
public String resolvedApiBaseUrl() {
return hasText(apiBaseUrl) ? apiBaseUrl : "https://api.paystack.co";
}
public String resolvedWebhookSecret() {
return hasText(webhookSecret) ? webhookSecret : secretKey;
}
public boolean hasSecretKey() {
return hasText(secretKey);
}
public boolean hasCallbackUrl() {
return hasText(callbackUrl);
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -14,7 +14,6 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@@ -32,7 +31,6 @@ public class SecurityConfig {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfRequestHandler)
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/payments/paystack/webhook", "POST"))
)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
@@ -43,7 +41,6 @@ public class SecurityConfig {
.requestMatchers("/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/checkout/quote").permitAll()
.requestMatchers(HttpMethod.POST, "/api/payments/paystack/webhook").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/products/**").hasRole("ADMIN")
@@ -1,14 +1,16 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.CheckoutQuoteRequest;
import com.andwati.orders.dto.request.CreateOrderRequest;
import com.andwati.orders.dto.response.CheckoutQuoteResponse;
import com.andwati.orders.dto.response.SimulatedCheckoutResponse;
import com.andwati.orders.services.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Checkout", description = "Quote ecommerce checkout totals")
@Tag(name = "Checkout", description = "Quote totals and simulate ecommerce checkout")
@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {
@@ -27,4 +29,13 @@ public class CheckoutController {
public CheckoutQuoteResponse quote(@Valid @RequestBody CheckoutQuoteRequest request) {
return paymentService.quote(request);
}
@Operation(
summary = "Simulate checkout",
description = "Creates a pending order, optional delivery ride, and a paid local payment record without an external provider."
)
@PostMapping("/simulate")
public SimulatedCheckoutResponse simulate(@Valid @RequestBody CreateOrderRequest request) {
return paymentService.simulateCheckout(request);
}
}
@@ -1,51 +0,0 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.PaystackInitializeRequest;
import com.andwati.orders.dto.response.PaystackInitializeResponse;
import com.andwati.orders.dto.response.PaystackVerifyResponse;
import com.andwati.orders.services.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Paystack Payments", description = "Initialize and verify Paystack checkout payments")
@RestController
@RequestMapping("/api/payments/paystack")
public class PaystackPaymentController {
private final PaymentService paymentService;
public PaystackPaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(
summary = "Initialize Paystack checkout",
description = "Creates pending order, optional ride, payment record, and returns a Paystack authorization URL."
)
@PostMapping("/initialize")
public PaystackInitializeResponse initialize(@Valid @RequestBody PaystackInitializeRequest request) {
return paymentService.initialize(request);
}
@Operation(
summary = "Verify Paystack payment",
description = "Verifies the Paystack transaction and marks the payment paid only when reference, amount, and currency match."
)
@PostMapping("/verify/{reference}")
public PaystackVerifyResponse verify(@PathVariable String reference) {
return paymentService.verify(reference);
}
@Operation(summary = "Receive Paystack webhook events")
@PostMapping("/webhook")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void webhook(
@RequestBody String body,
@RequestHeader(name = "x-paystack-signature", required = false) String signature
) {
paymentService.handleWebhook(body, signature);
}
}
@@ -1,19 +0,0 @@
package com.andwati.orders.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import java.util.List;
public record PaystackInitializeRequest(
@Valid
@Size(min = 1, message = "items must contain at least one item")
List<CreateOrderItemRequest> items,
@Valid
CreateOrderRideRequest deliveryRide
) {
public CreateOrderRequest toCreateOrderRequest() {
return new CreateOrderRequest(items, deliveryRide);
}
}
@@ -1,18 +0,0 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.PaymentStatus;
import java.math.BigDecimal;
import java.util.UUID;
public record PaystackInitializeResponse(
String authorizationUrl,
String accessCode,
String reference,
UUID orderId,
UUID rideId,
BigDecimal amount,
String currency,
PaymentStatus status
) {
}
@@ -5,13 +5,13 @@ import com.andwati.orders.model.PaymentStatus;
import java.math.BigDecimal;
import java.util.UUID;
public record PaystackVerifyResponse(
public record SimulatedCheckoutResponse(
String reference,
PaymentStatus status,
UUID orderId,
UUID rideId,
BigDecimal amount,
String currency,
PaymentStatus status,
String message
) {
}
@@ -49,12 +49,6 @@ public class Payment {
@Column(nullable = false, length = 30)
private PaymentStatus status = PaymentStatus.PENDING;
@Column(length = 512)
private String authorizationUrl;
@Column(length = 120)
private String accessCode;
@Lob
private String rawVerificationPayload;
@@ -194,24 +188,6 @@ public class Payment {
return this;
}
public String getAuthorizationUrl() {
return authorizationUrl;
}
public Payment setAuthorizationUrl(String authorizationUrl) {
this.authorizationUrl = authorizationUrl;
return this;
}
public String getAccessCode() {
return accessCode;
}
public Payment setAccessCode(String accessCode) {
this.accessCode = accessCode;
return this;
}
public String getRawVerificationPayload() {
return rawVerificationPayload;
}
@@ -1,13 +1,10 @@
package com.andwati.orders.services;
import com.andwati.orders.config.PaystackProperties;
import com.andwati.orders.dto.request.CheckoutQuoteRequest;
import com.andwati.orders.dto.request.CreateOrderRequest;
import com.andwati.orders.dto.request.PaystackInitializeRequest;
import com.andwati.orders.dto.response.CheckoutQuoteResponse;
import com.andwati.orders.dto.response.FareCalculationResponse;
import com.andwati.orders.dto.response.PaystackInitializeResponse;
import com.andwati.orders.dto.response.PaystackVerifyResponse;
import com.andwati.orders.dto.response.SimulatedCheckoutResponse;
import com.andwati.orders.exception.InsufficientStockException;
import com.andwati.orders.exception.InventoryNotFoundException;
import com.andwati.orders.exception.ProductNotFoundException;
@@ -15,19 +12,12 @@ import com.andwati.orders.model.*;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.PaymentRepository;
import com.andwati.orders.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@@ -46,9 +36,6 @@ public class PaymentService {
private final FareCalculationService fareCalculationService;
private final OrderService orderService;
private final AppUserService appUserService;
private final PaystackClient paystackClient;
private final PaystackProperties paystackProperties;
private final ObjectMapper objectMapper;
public PaymentService(
ProductRepository productRepository,
@@ -56,10 +43,7 @@ public class PaymentService {
PaymentRepository paymentRepository,
FareCalculationService fareCalculationService,
OrderService orderService,
AppUserService appUserService,
PaystackClient paystackClient,
PaystackProperties paystackProperties,
ObjectMapper objectMapper
AppUserService appUserService
) {
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
@@ -67,9 +51,6 @@ public class PaymentService {
this.fareCalculationService = fareCalculationService;
this.orderService = orderService;
this.appUserService = appUserService;
this.paystackClient = paystackClient;
this.paystackProperties = paystackProperties;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
@@ -79,7 +60,7 @@ public class PaymentService {
}
@Transactional
public PaystackInitializeResponse initialize(PaystackInitializeRequest request) {
public SimulatedCheckoutResponse simulateCheckout(CreateOrderRequest request) {
AppUser customer = appUserService.getCurrentUser();
if (customer.getRole() != Role.CUSTOMER) {
throw new IllegalArgumentException("Only customers can checkout");
@@ -88,7 +69,7 @@ public class PaymentService {
CheckoutQuote quote = calculateQuote(request.items(), request.deliveryRide());
PendingOrderResult pendingOrder = orderService.createPendingOrder(
customer,
request.toCreateOrderRequest(),
request,
quote.rideFare(),
quote.currency()
);
@@ -106,76 +87,9 @@ public class PaymentService {
.setStatus(PaymentStatus.PENDING);
paymentRepository.save(payment);
payment.markPaid(buildSimulatedPayload(customer, payment));
JsonNode paystackData = paystackClient.initializeTransaction(
customer.getUsername(),
payment.getAmountSubunits(),
payment.getCurrency(),
payment.getReference(),
buildMetadata(customer, payment)
);
payment
.setAuthorizationUrl(paystackData.path("authorization_url").asText())
.setAccessCode(paystackData.path("access_code").asText());
return toInitializeResponse(payment);
}
@Transactional
public PaystackVerifyResponse verify(String reference) {
Payment payment = getPayment(reference);
assertCanAccess(payment);
return verifyPayment(payment);
}
@Transactional
public void handleWebhook(String body, String signature) {
verifyWebhookSignature(body, signature);
JsonNode event;
try {
event = objectMapper.readTree(body);
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("Webhook payload is not valid JSON");
}
if (!"charge.success".equals(event.path("event").asText())) {
return;
}
String reference = event.path("data").path("reference").asText(null);
if (reference == null || reference.isBlank()) {
return;
}
paymentRepository.findByReference(reference).ifPresent(this::verifyPayment);
}
private PaystackVerifyResponse verifyPayment(Payment payment) {
JsonNode verification = paystackClient.verifyTransaction(payment.getReference());
JsonNode data = verification.path("data");
String rawPayload = toJson(verification);
boolean success = "success".equals(data.path("status").asText());
boolean matchesReference = payment.getReference().equals(data.path("reference").asText());
boolean matchesAmount = payment.getAmountSubunits() == data.path("amount").asLong();
boolean matchesCurrency = payment.getCurrency().equalsIgnoreCase(data.path("currency").asText());
if (success && matchesReference && matchesAmount && matchesCurrency) {
payment.markPaid(rawPayload);
return toVerifyResponse(payment, "Payment verified");
}
PaymentStatus nextStatus = mapPaystackStatus(data.path("status").asText());
payment.markFailed(nextStatus, rawPayload);
if (!success) {
return toVerifyResponse(payment, data.path("gateway_response").asText("Payment was not successful"));
}
throw new IllegalArgumentException("Paystack verification did not match the stored payment");
return toSimulatedCheckoutResponse(payment);
}
private CheckoutQuote calculateQuote(
@@ -281,33 +195,22 @@ public class PaymentService {
);
}
private PaystackInitializeResponse toInitializeResponse(Payment payment) {
return new PaystackInitializeResponse(
payment.getAuthorizationUrl(),
payment.getAccessCode(),
private SimulatedCheckoutResponse toSimulatedCheckoutResponse(Payment payment) {
return new SimulatedCheckoutResponse(
payment.getReference(),
payment.getOrder().getId(),
payment.getRide() == null ? null : payment.getRide().getId(),
payment.getAmountMajor(),
payment.getCurrency(),
payment.getStatus()
);
}
private PaystackVerifyResponse toVerifyResponse(Payment payment, String message) {
return new PaystackVerifyResponse(
payment.getReference(),
payment.getStatus(),
payment.getOrder().getId(),
payment.getRide() == null ? null : payment.getRide().getId(),
payment.getAmountMajor(),
payment.getCurrency(),
message
"Checkout simulated successfully"
);
}
private Map<String, Object> buildMetadata(AppUser customer, Payment payment) {
private String buildSimulatedPayload(AppUser customer, Payment payment) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("provider", "simulated");
metadata.put("reference", payment.getReference());
metadata.put("orderId", payment.getOrder().getId().toString());
metadata.put("rideId", payment.getRide() == null ? null : payment.getRide().getId().toString());
metadata.put("userId", customer.getId().toString());
@@ -315,7 +218,8 @@ public class PaymentService {
metadata.put("rideFare", payment.getRideFare());
metadata.put("grandTotal", payment.getAmountMajor());
metadata.put("currency", payment.getCurrency());
return metadata;
metadata.put("status", PaymentStatus.PAID.name());
return metadata.toString();
}
private long toSubunits(BigDecimal amount) {
@@ -328,80 +232,6 @@ public class PaymentService {
return "CYM-" + UUID.randomUUID();
}
private Payment getPayment(String reference) {
return paymentRepository.findByReference(reference)
.orElseThrow(() -> new IllegalArgumentException("Payment reference was not found"));
}
private void assertCanAccess(Payment payment) {
AppUser currentUser = appUserService.getCurrentUser();
if (currentUser.getRole() == Role.ADMIN) {
return;
}
AppUser customer = payment.getOrder().getCustomer();
if (currentUser.getRole() == Role.CUSTOMER
&& customer != null
&& currentUser.getId().equals(customer.getId())) {
return;
}
throw new IllegalArgumentException("You cannot access this payment");
}
private PaymentStatus mapPaystackStatus(String status) {
return switch (status) {
case "abandoned" -> PaymentStatus.ABANDONED;
case "reversed" -> PaymentStatus.REFUNDED;
default -> PaymentStatus.FAILED;
};
}
private void verifyWebhookSignature(String body, String signature) {
String secret = paystackProperties.resolvedWebhookSecret();
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("PAYSTACK_WEBHOOK_SECRET or PAYSTACK_SECRET_KEY is not configured");
}
if (signature == null || signature.isBlank()) {
throw new IllegalArgumentException("Paystack signature is required");
}
try {
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
String expected = HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
if (!constantTimeEquals(expected, signature)) {
throw new IllegalArgumentException("Invalid Paystack signature");
}
} catch (IllegalArgumentException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("Could not verify Paystack signature", exception);
}
}
private boolean constantTimeEquals(String expected, String actual) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8);
if (expectedBytes.length != actualBytes.length) {
return false;
}
int result = 0;
for (int index = 0; index < expectedBytes.length; index++) {
result |= expectedBytes[index] ^ actualBytes[index];
}
return result == 0;
}
private String toJson(JsonNode node) {
try {
return objectMapper.writeValueAsString(node);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Could not serialize Paystack verification payload", exception);
}
}
private record CheckoutQuote(
BigDecimal productSubtotal,
BigDecimal rideFare,
@@ -1,80 +0,0 @@
package com.andwati.orders.services;
import com.andwati.orders.config.PaystackProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class PaystackClient {
private final PaystackProperties paystackProperties;
private final RestClient restClient;
public PaystackClient(PaystackProperties paystackProperties, RestClient.Builder restClientBuilder) {
this.paystackProperties = paystackProperties;
this.restClient = restClientBuilder
.baseUrl(paystackProperties.resolvedApiBaseUrl())
.build();
}
public JsonNode initializeTransaction(
String email,
long amountSubunits,
String currency,
String reference,
Map<String, Object> metadata
) {
assertConfigured();
Map<String, Object> body = new LinkedHashMap<>();
body.put("email", email);
body.put("amount", String.valueOf(amountSubunits));
body.put("currency", currency);
body.put("reference", reference);
body.put("metadata", metadata);
if (paystackProperties.hasCallbackUrl()) {
body.put("callback_url", paystackProperties.callbackUrl());
}
JsonNode response = restClient.post()
.uri("/transaction/initialize")
.headers(headers -> headers.setBearerAuth(paystackProperties.secretKey()))
.body(body)
.retrieve()
.body(JsonNode.class);
assertSuccessful(response, "Paystack transaction initialization failed");
return response.path("data");
}
public JsonNode verifyTransaction(String reference) {
assertConfigured();
JsonNode response = restClient.get()
.uri("/transaction/verify/{reference}", reference)
.headers(headers -> headers.setBearerAuth(paystackProperties.secretKey()))
.retrieve()
.body(JsonNode.class);
assertSuccessful(response, "Paystack transaction verification failed");
return response;
}
private void assertConfigured() {
if (!paystackProperties.hasSecretKey()) {
throw new IllegalStateException("PAYSTACK_SECRET_KEY is not configured");
}
}
private void assertSuccessful(JsonNode response, String fallbackMessage) {
if (response == null || !response.path("status").asBoolean(false)) {
String message = response == null ? fallbackMessage : response.path("message").asText(fallbackMessage);
throw new IllegalStateException(message);
}
}
}
@@ -20,12 +20,6 @@ fare.minimum-fare=${FARE_MINIMUM_FARE:150.00}
fare.default-surge-multiplier=${FARE_DEFAULT_SURGE_MULTIPLIER:1.0}
fare.currency=${FARE_CURRENCY:KES}
paystack.secret-key=${PAYSTACK_SECRET_KEY:}
paystack.public-key=${PAYSTACK_PUBLIC_KEY:}
paystack.callback-url=${PAYSTACK_CALLBACK_URL:}
paystack.webhook-secret=${PAYSTACK_WEBHOOK_SECRET:}
paystack.api-base-url=${PAYSTACK_API_BASE_URL:https://api.paystack.co}
app.seed-data=${SEED_DATA:true}
app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000}
springdoc.api-docs.path=/api-docs
+1
View File
@@ -11,3 +11,4 @@ dist-ssr
.vinxi
__unconfig*
todos.json
.pnpm-store/
+7 -20
View File
@@ -2,9 +2,8 @@ import { apiRequest } from "#/api/client";
import type {
CheckoutQuote,
CheckoutQuoteRequest,
PaystackInitializeRequest,
PaystackInitializeResponse,
PaystackVerifyResponse,
SimulatedCheckoutRequest,
SimulatedCheckoutResponse,
} from "#/types/payment";
export function getCheckoutQuote(
@@ -18,21 +17,9 @@ export function getCheckoutQuote(
});
}
export function initializePaystackPayment(request: PaystackInitializeRequest) {
return apiRequest<PaystackInitializeResponse>(
"/payments/paystack/initialize",
{
method: "POST",
body: request,
},
);
}
export function verifyPaystackPayment(reference: string) {
return apiRequest<PaystackVerifyResponse>(
`/payments/paystack/verify/${encodeURIComponent(reference)}`,
{
method: "POST",
},
);
export function simulateCheckout(request: SimulatedCheckoutRequest) {
return apiRequest<SimulatedCheckoutResponse>("/checkout/simulate", {
method: "POST",
body: request,
});
}
+5 -24
View File
@@ -1,15 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCheckoutQuote,
initializePaystackPayment,
verifyPaystackPayment,
} from "#/api/payments";
import { getCheckoutQuote, simulateCheckout } from "#/api/payments";
import { inventoryQueryKeys } from "#/hooks/useInventory";
import { orderQueryKeys } from "#/hooks/useOrders";
import { productQueryKeys } from "#/hooks/useProducts";
import type {
CheckoutQuoteRequest,
PaystackInitializeRequest,
SimulatedCheckoutRequest,
} from "#/types/payment";
export const paymentQueryKeys = {
@@ -29,27 +25,12 @@ export function useCheckoutQuote(
});
}
export function useInitializePaystackPayment() {
export function useSimulateCheckout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: PaystackInitializeRequest) =>
initializePaystackPayment(request),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: inventoryQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
]);
},
});
}
export function useVerifyPaystackPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: verifyPaystackPayment,
mutationFn: (request: SimulatedCheckoutRequest) =>
simulateCheckout(request),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
-22
View File
@@ -35,7 +35,6 @@ import { Route as DriverRidesIndexRouteImport } from './routes/driver.rides.inde
import { Route as AdminRidesIndexRouteImport } from './routes/admin.rides.index'
import { Route as AdminProductsIndexRouteImport } from './routes/admin.products.index'
import { Route as AdminOrdersIndexRouteImport } from './routes/admin.orders.index'
import { Route as PaymentsPaystackCallbackRouteImport } from './routes/payments.paystack.callback'
import { Route as DriverRidesRequestedRouteImport } from './routes/driver.rides.requested'
import { Route as DriverRidesCompletedRouteImport } from './routes/driver.rides.completed'
import { Route as DriverRidesAcceptedRouteImport } from './routes/driver.rides.accepted'
@@ -174,12 +173,6 @@ const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
path: '/',
getParentRoute: () => AdminOrdersRoute,
} as any)
const PaymentsPaystackCallbackRoute =
PaymentsPaystackCallbackRouteImport.update({
id: '/payments/paystack/callback',
path: '/payments/paystack/callback',
getParentRoute: () => rootRouteImport,
} as any)
const DriverRidesRequestedRoute = DriverRidesRequestedRouteImport.update({
id: '/requested',
path: '/requested',
@@ -246,7 +239,6 @@ export interface FileRoutesByFullPath {
'/driver/rides/accepted': typeof DriverRidesAcceptedRoute
'/driver/rides/completed': typeof DriverRidesCompletedRoute
'/driver/rides/requested': typeof DriverRidesRequestedRoute
'/payments/paystack/callback': typeof PaymentsPaystackCallbackRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
'/admin/products/': typeof AdminProductsIndexRoute
'/admin/rides/': typeof AdminRidesIndexRoute
@@ -274,7 +266,6 @@ export interface FileRoutesByTo {
'/driver/rides/accepted': typeof DriverRidesAcceptedRoute
'/driver/rides/completed': typeof DriverRidesCompletedRoute
'/driver/rides/requested': typeof DriverRidesRequestedRoute
'/payments/paystack/callback': typeof PaymentsPaystackCallbackRoute
'/admin/orders': typeof AdminOrdersIndexRoute
'/admin/products': typeof AdminProductsIndexRoute
'/admin/rides': typeof AdminRidesIndexRoute
@@ -311,7 +302,6 @@ export interface FileRoutesById {
'/driver/rides/accepted': typeof DriverRidesAcceptedRoute
'/driver/rides/completed': typeof DriverRidesCompletedRoute
'/driver/rides/requested': typeof DriverRidesRequestedRoute
'/payments/paystack/callback': typeof PaymentsPaystackCallbackRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
'/admin/products/': typeof AdminProductsIndexRoute
'/admin/rides/': typeof AdminRidesIndexRoute
@@ -349,7 +339,6 @@ export interface FileRouteTypes {
| '/driver/rides/accepted'
| '/driver/rides/completed'
| '/driver/rides/requested'
| '/payments/paystack/callback'
| '/admin/orders/'
| '/admin/products/'
| '/admin/rides/'
@@ -377,7 +366,6 @@ export interface FileRouteTypes {
| '/driver/rides/accepted'
| '/driver/rides/completed'
| '/driver/rides/requested'
| '/payments/paystack/callback'
| '/admin/orders'
| '/admin/products'
| '/admin/rides'
@@ -413,7 +401,6 @@ export interface FileRouteTypes {
| '/driver/rides/accepted'
| '/driver/rides/completed'
| '/driver/rides/requested'
| '/payments/paystack/callback'
| '/admin/orders/'
| '/admin/products/'
| '/admin/rides/'
@@ -428,7 +415,6 @@ export interface RootRouteChildren {
ProductsRoute: typeof ProductsRouteWithChildren
RidesRoute: typeof RidesRouteWithChildren
DriverRidesRoute: typeof DriverRidesRouteWithChildren
PaymentsPaystackCallbackRoute: typeof PaymentsPaystackCallbackRoute
}
declare module '@tanstack/react-router' {
@@ -615,13 +601,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminOrdersIndexRouteImport
parentRoute: typeof AdminOrdersRoute
}
'/payments/paystack/callback': {
id: '/payments/paystack/callback'
path: '/payments/paystack/callback'
fullPath: '/payments/paystack/callback'
preLoaderRoute: typeof PaymentsPaystackCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/driver/rides/requested': {
id: '/driver/rides/requested'
path: '/requested'
@@ -809,7 +788,6 @@ const rootRouteChildren: RootRouteChildren = {
ProductsRoute: ProductsRouteWithChildren,
RidesRoute: RidesRouteWithChildren,
DriverRidesRoute: DriverRidesRouteWithChildren,
PaymentsPaystackCallbackRoute: PaymentsPaystackCallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+14 -16
View File
@@ -14,10 +14,7 @@ import { Separator } from "#/components/ui/separator";
import { formatCurrency } from "#/hooks/formatCurrency";
import { useAuth } from "#/hooks/useAuth";
import { useCart } from "#/hooks/useCart";
import {
useCheckoutQuote,
useInitializePaystackPayment,
} from "#/hooks/usePayments";
import { useCheckoutQuote, useSimulateCheckout } from "#/hooks/usePayments";
import { navigateToRoleHome } from "#/lib/auth-navigation";
export const Route = createFileRoute("/cart")({
@@ -28,7 +25,7 @@ function CartPage() {
const cart = useCart();
const { user, login, register, logout } = useAuth();
const navigate = useNavigate();
const initializePayment = useInitializePaystackPayment();
const simulateCheckout = useSimulateCheckout();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
const [requestRide, setRequestRide] = useState(false);
const [rideForm, setRideForm] = useState({
@@ -125,11 +122,12 @@ function CartPage() {
}
try {
const payment = await initializePayment.mutateAsync(checkoutRequest);
toast.success("Redirecting to Paystack", {
description: `Payment ${payment.reference} is ready.`,
const checkout = await simulateCheckout.mutateAsync(checkoutRequest);
cart.clearCart();
toast.success("Checkout complete", {
description: `Order ${checkout.orderId.slice(0, 8)} is ready for fulfillment.`,
});
window.location.href = payment.authorizationUrl;
await navigate({ to: "/orders" });
} catch (error) {
toast.error("Checkout failed", {
description:
@@ -326,14 +324,14 @@ function CartPage() {
</div>
<Button
type="button"
disabled={initializePayment.isPending}
disabled={simulateCheckout.isPending}
onClick={checkout}
className="w-full"
>
<CreditCard className="mr-2 size-4" />
{initializePayment.isPending
? "Opening Paystack..."
: "Pay with Paystack"}
{simulateCheckout.isPending
? "Completing checkout..."
: "Simulate checkout"}
</Button>
{!user ? (
<div className="grid grid-cols-2 gap-2">
@@ -362,11 +360,11 @@ function CartPage() {
}
/>
) : null}
{initializePayment.error ? (
{simulateCheckout.error ? (
<ErrorState
message={
initializePayment.error instanceof Error
? initializePayment.error.message
simulateCheckout.error instanceof Error
? simulateCheckout.error.message
: "Checkout failed"
}
/>
@@ -1,136 +0,0 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { SiteHeader } from "#/components/store/SiteHeader";
import { Button } from "#/components/ui/button";
import { Card, CardContent } from "#/components/ui/card";
import { useAuth } from "#/hooks/useAuth";
import { useCart } from "#/hooks/useCart";
import { useVerifyPaystackPayment } from "#/hooks/usePayments";
export const Route = createFileRoute("/payments/paystack/callback")({
validateSearch: (search: Record<string, unknown>) => ({
reference: typeof search.reference === "string" ? search.reference : "",
}),
component: PaystackCallbackPage,
});
function PaystackCallbackPage() {
const { user, logout } = useAuth();
const cart = useCart();
const navigate = useNavigate();
const { reference } = Route.useSearch();
const verifyPayment = useVerifyPaystackPayment();
const startedRef = useRef(false);
useEffect(() => {
if (!reference || startedRef.current) {
return;
}
startedRef.current = true;
verifyPayment.mutate(reference, {
onSuccess: (payment) => {
if (payment.status === "PAID") {
cart.clearCart();
toast.success("Payment verified", {
description: `Order ${payment.orderId.slice(0, 8)} is ready for fulfillment.`,
});
return;
}
toast.error("Payment not completed", {
description: payment.message,
});
},
onError: (error) => {
toast.error("Payment verification failed", {
description:
error instanceof Error
? error.message
: "Please contact support with your reference.",
});
},
});
}, [cart, reference, verifyPayment]);
const paid = verifyPayment.data?.status === "PAID";
const failed =
!reference ||
verifyPayment.isError ||
(verifyPayment.data && verifyPayment.data.status !== "PAID");
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => navigate({ to: "/cart" })}
onRegister={() => navigate({ to: "/cart" })}
onLogout={logout}
/>
<div className="mx-auto flex max-w-2xl px-4 py-12">
<Card className="w-full">
<CardContent className="space-y-5 p-6">
{verifyPayment.isPending ? (
<div className="flex items-center gap-3">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<div>
<h1 className="text-xl font-semibold">Verifying payment</h1>
<p className="text-sm text-muted-foreground">
Reference {reference}
</p>
</div>
</div>
) : null}
{paid ? (
<div className="rounded-md border p-4">
<div className="flex gap-3">
<CheckCircle2 className="mt-0.5 size-4 text-emerald-600" />
<div>
<h1 className="font-semibold">Payment verified</h1>
<p className="text-sm text-muted-foreground">
Order {verifyPayment.data?.orderId.slice(0, 8)} is pending
fulfillment.
</p>
</div>
</div>
</div>
) : null}
{failed ? (
<div className="rounded-md border border-destructive/40 p-4 text-destructive">
<div className="flex gap-3">
<XCircle className="mt-0.5 size-4" />
<div>
<h1 className="font-semibold">
Payment could not be verified
</h1>
<p className="text-sm">
{reference
? (verifyPayment.data?.message ??
verifyPayment.error?.message ??
"Please contact support with your reference.")
: "The Paystack callback did not include a reference."}
</p>
</div>
</div>
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<Button asChild>
<Link to="/orders">View orders</Link>
</Button>
<Button asChild variant="outline">
<Link to="/cart">Back to cart</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</main>
);
}
+2 -13
View File
@@ -10,25 +10,14 @@ export type CheckoutQuote = {
expiresAt: string;
};
export type PaystackInitializeRequest = CreateOrderRequest;
export type SimulatedCheckoutRequest = CreateOrderRequest;
export type PaystackInitializeResponse = {
authorizationUrl: string;
accessCode: string;
export type SimulatedCheckoutResponse = {
reference: string;
orderId: string;
rideId: string | null;
amount: number;
currency: string;
status: "PENDING" | "PAID" | "FAILED" | "ABANDONED" | "REFUNDED";
};
export type PaystackVerifyResponse = {
reference: string;
status: "PENDING" | "PAID" | "FAILED" | "ABANDONED" | "REFUNDED";
orderId: string;
rideId: string | null;
amount: number;
currency: string;
message: string;
};
-8
View File
@@ -15,14 +15,6 @@ services:
sync: false
- key: APP_CORS_ALLOWED_ORIGINS
value: https://cymelle-web.onrender.com
- key: PAYSTACK_SECRET_KEY
sync: false
- key: PAYSTACK_PUBLIC_KEY
sync: false
- key: PAYSTACK_CALLBACK_URL
value: https://cymelle-web.onrender.com/payments/paystack/callback
- key: PAYSTACK_WEBHOOK_SECRET
sync: false
- type: web
name: cymelle-web