From 6a98e8984e61f971e8ef83709f08ed99bce1af58 Mon Sep 17 00:00:00 2001 From: andwati Date: Mon, 15 Jun 2026 11:38:04 +0300 Subject: [PATCH] purge paystack --- .env.example | 6 - DEPLOYMENT.md | 5 - README.md | 69 +++--- compose.yml | 7 +- orders-backend/.env.example | 6 - .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../orders/OrdersBackendApplication.java | 4 +- .../orders/config/PaystackProperties.java | 32 --- .../andwati/orders/config/SecurityConfig.java | 3 - .../orders/controller/CheckoutController.java | 13 +- .../controller/PaystackPaymentController.java | 51 ----- .../request/PaystackInitializeRequest.java | 19 -- .../response/PaystackInitializeResponse.java | 18 -- ...se.java => SimulatedCheckoutResponse.java} | 4 +- .../com/andwati/orders/model/Payment.java | 24 --- .../orders/services/PaymentService.java | 198 ++---------------- .../orders/services/PaystackClient.java | 80 ------- .../src/main/resources/application.properties | 6 - orders-frontend/.gitignore | 1 + orders-frontend/src/api/payments.ts | 27 +-- orders-frontend/src/hooks/usePayments.ts | 29 +-- orders-frontend/src/routeTree.gen.ts | 22 -- orders-frontend/src/routes/cart.tsx | 30 ++- .../src/routes/payments.paystack.callback.tsx | 136 ------------ orders-frontend/src/types/payment.ts | 15 +- render.yaml | 8 - 26 files changed, 85 insertions(+), 730 deletions(-) delete mode 100644 orders-backend/src/main/java/com/andwati/orders/config/PaystackProperties.java delete mode 100644 orders-backend/src/main/java/com/andwati/orders/controller/PaystackPaymentController.java delete mode 100644 orders-backend/src/main/java/com/andwati/orders/dto/request/PaystackInitializeRequest.java delete mode 100644 orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackInitializeResponse.java rename orders-backend/src/main/java/com/andwati/orders/dto/response/{PaystackVerifyResponse.java => SimulatedCheckoutResponse.java} (88%) delete mode 100644 orders-backend/src/main/java/com/andwati/orders/services/PaystackClient.java delete mode 100644 orders-frontend/src/routes/payments.paystack.callback.tsx diff --git a/.env.example b/.env.example index 921d2a4..fc12dbd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3960601..a61804f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 diff --git a/README.md b/README.md index d72c3d8..e419071 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/compose.yml b/compose.yml index 4145764..887ccdb 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/orders-backend/.env.example b/orders-backend/.env.example index 852f522..9eb18f8 100644 --- a/orders-backend/.env.example +++ b/orders-backend/.env.example @@ -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 diff --git a/orders-backend/gradle/wrapper/gradle-wrapper.properties b/orders-backend/gradle/wrapper/gradle-wrapper.properties index 95dae55..c2bad78 100644 --- a/orders-backend/gradle/wrapper/gradle-wrapper.properties +++ b/orders-backend/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java b/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java index d45276e..47a67e0 100644 --- a/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java +++ b/orders-backend/src/main/java/com/andwati/orders/OrdersBackendApplication.java @@ -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 { diff --git a/orders-backend/src/main/java/com/andwati/orders/config/PaystackProperties.java b/orders-backend/src/main/java/com/andwati/orders/config/PaystackProperties.java deleted file mode 100644 index 9524042..0000000 --- a/orders-backend/src/main/java/com/andwati/orders/config/PaystackProperties.java +++ /dev/null @@ -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(); - } -} diff --git a/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java b/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java index d475dfb..0ac6667 100644 --- a/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java +++ b/orders-backend/src/main/java/com/andwati/orders/config/SecurityConfig.java @@ -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") diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/CheckoutController.java b/orders-backend/src/main/java/com/andwati/orders/controller/CheckoutController.java index f9798a5..4d689b8 100644 --- a/orders-backend/src/main/java/com/andwati/orders/controller/CheckoutController.java +++ b/orders-backend/src/main/java/com/andwati/orders/controller/CheckoutController.java @@ -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); + } } diff --git a/orders-backend/src/main/java/com/andwati/orders/controller/PaystackPaymentController.java b/orders-backend/src/main/java/com/andwati/orders/controller/PaystackPaymentController.java deleted file mode 100644 index d544f75..0000000 --- a/orders-backend/src/main/java/com/andwati/orders/controller/PaystackPaymentController.java +++ /dev/null @@ -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); - } -} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/request/PaystackInitializeRequest.java b/orders-backend/src/main/java/com/andwati/orders/dto/request/PaystackInitializeRequest.java deleted file mode 100644 index bdec36c..0000000 --- a/orders-backend/src/main/java/com/andwati/orders/dto/request/PaystackInitializeRequest.java +++ /dev/null @@ -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 items, - - @Valid - CreateOrderRideRequest deliveryRide -) { - public CreateOrderRequest toCreateOrderRequest() { - return new CreateOrderRequest(items, deliveryRide); - } -} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackInitializeResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackInitializeResponse.java deleted file mode 100644 index b571d35..0000000 --- a/orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackInitializeResponse.java +++ /dev/null @@ -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 -) { -} diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackVerifyResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/SimulatedCheckoutResponse.java similarity index 88% rename from orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackVerifyResponse.java rename to orders-backend/src/main/java/com/andwati/orders/dto/response/SimulatedCheckoutResponse.java index d1ddebc..da5be9b 100644 --- a/orders-backend/src/main/java/com/andwati/orders/dto/response/PaystackVerifyResponse.java +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/SimulatedCheckoutResponse.java @@ -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 ) { } diff --git a/orders-backend/src/main/java/com/andwati/orders/model/Payment.java b/orders-backend/src/main/java/com/andwati/orders/model/Payment.java index d746f05..05c7a7a 100644 --- a/orders-backend/src/main/java/com/andwati/orders/model/Payment.java +++ b/orders-backend/src/main/java/com/andwati/orders/model/Payment.java @@ -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; } diff --git a/orders-backend/src/main/java/com/andwati/orders/services/PaymentService.java b/orders-backend/src/main/java/com/andwati/orders/services/PaymentService.java index d0aca61..df58f50 100644 --- a/orders-backend/src/main/java/com/andwati/orders/services/PaymentService.java +++ b/orders-backend/src/main/java/com/andwati/orders/services/PaymentService.java @@ -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 buildMetadata(AppUser customer, Payment payment) { + private String buildSimulatedPayload(AppUser customer, Payment payment) { Map 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, diff --git a/orders-backend/src/main/java/com/andwati/orders/services/PaystackClient.java b/orders-backend/src/main/java/com/andwati/orders/services/PaystackClient.java deleted file mode 100644 index f41ad39..0000000 --- a/orders-backend/src/main/java/com/andwati/orders/services/PaystackClient.java +++ /dev/null @@ -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 metadata - ) { - assertConfigured(); - - Map 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); - } - } -} diff --git a/orders-backend/src/main/resources/application.properties b/orders-backend/src/main/resources/application.properties index 8b09274..46c9a07 100644 --- a/orders-backend/src/main/resources/application.properties +++ b/orders-backend/src/main/resources/application.properties @@ -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 diff --git a/orders-frontend/.gitignore b/orders-frontend/.gitignore index 8b25bb5..b62f1fe 100644 --- a/orders-frontend/.gitignore +++ b/orders-frontend/.gitignore @@ -11,3 +11,4 @@ dist-ssr .vinxi __unconfig* todos.json +.pnpm-store/ \ No newline at end of file diff --git a/orders-frontend/src/api/payments.ts b/orders-frontend/src/api/payments.ts index b97b284..f40dbb5 100644 --- a/orders-frontend/src/api/payments.ts +++ b/orders-frontend/src/api/payments.ts @@ -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( - "/payments/paystack/initialize", - { - method: "POST", - body: request, - }, - ); -} - -export function verifyPaystackPayment(reference: string) { - return apiRequest( - `/payments/paystack/verify/${encodeURIComponent(reference)}`, - { - method: "POST", - }, - ); +export function simulateCheckout(request: SimulatedCheckoutRequest) { + return apiRequest("/checkout/simulate", { + method: "POST", + body: request, + }); } diff --git a/orders-frontend/src/hooks/usePayments.ts b/orders-frontend/src/hooks/usePayments.ts index b1cf538..bf70a89 100644 --- a/orders-frontend/src/hooks/usePayments.ts +++ b/orders-frontend/src/hooks/usePayments.ts @@ -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 }), diff --git a/orders-frontend/src/routeTree.gen.ts b/orders-frontend/src/routeTree.gen.ts index eeb1991..cf199a9 100644 --- a/orders-frontend/src/routeTree.gen.ts +++ b/orders-frontend/src/routeTree.gen.ts @@ -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) diff --git a/orders-frontend/src/routes/cart.tsx b/orders-frontend/src/routes/cart.tsx index 9c9d4b3..69f3885 100644 --- a/orders-frontend/src/routes/cart.tsx +++ b/orders-frontend/src/routes/cart.tsx @@ -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() { {!user ? (
@@ -362,11 +360,11 @@ function CartPage() { } /> ) : null} - {initializePayment.error ? ( + {simulateCheckout.error ? ( diff --git a/orders-frontend/src/routes/payments.paystack.callback.tsx b/orders-frontend/src/routes/payments.paystack.callback.tsx deleted file mode 100644 index 838377a..0000000 --- a/orders-frontend/src/routes/payments.paystack.callback.tsx +++ /dev/null @@ -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) => ({ - 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 ( -
- navigate({ to: "/cart" })} - onRegister={() => navigate({ to: "/cart" })} - onLogout={logout} - /> - -
- - - {verifyPayment.isPending ? ( -
- -
-

Verifying payment

-

- Reference {reference} -

-
-
- ) : null} - - {paid ? ( -
-
- -
-

Payment verified

-

- Order {verifyPayment.data?.orderId.slice(0, 8)} is pending - fulfillment. -

-
-
-
- ) : null} - - {failed ? ( -
-
- -
-

- Payment could not be verified -

-

- {reference - ? (verifyPayment.data?.message ?? - verifyPayment.error?.message ?? - "Please contact support with your reference.") - : "The Paystack callback did not include a reference."} -

-
-
-
- ) : null} - -
- - -
-
-
-
-
- ); -} diff --git a/orders-frontend/src/types/payment.ts b/orders-frontend/src/types/payment.ts index 6f29e84..8019e9f 100644 --- a/orders-frontend/src/types/payment.ts +++ b/orders-frontend/src/types/payment.ts @@ -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; }; diff --git a/render.yaml b/render.yaml index c1b48d9..1d43d10 100644 --- a/render.yaml +++ b/render.yaml @@ -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