diff --git a/README.Docker.md b/README.Docker.md index f1366ff..153413f 100755 --- a/README.Docker.md +++ b/README.Docker.md @@ -1,22 +1,491 @@ -### Building and running your application +# Docker Deployment Guide - Payego -When you're ready, start your application by running: -`docker compose up --build`. +> Complete guide for deploying Payego using Docker and Docker Compose -Your application will be available at http://localhost:8080. +## ๐Ÿ“‹ Overview -### Deploying your application to the cloud +Payego's Docker setup includes: +- **Backend API** (Rust/Axum) with automatic migrations +- **PostgreSQL Database** with persistent storage +- **Prometheus** for metrics collection +- **Grafana** for metrics visualization -First, build your image, e.g.: `docker build -t myapp .`. -If your cloud uses a different CPU architecture than your development -machine (e.g., you are on a Mac M1 and your cloud provider is amd64), -you'll want to build the image for that platform, e.g.: -`docker build --platform=linux/amd64 -t myapp .`. +--- -Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. +## ๐Ÿš€ Quick Start -Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) -docs for more detail on building and pushing. +### Prerequisites +- Docker 20.10+ +- Docker Compose 2.0+ +- `.env` file configured (see below) -### References -* [Docker's Rust guide](https://docs.docker.com/language/rust/) \ No newline at end of file +### 1. Environment Setup + +Copy the example environment file and configure it: +```bash +cp .env.example .env +``` + +**Required environment variables:** +```env +# Database Configuration +DB_USER=postgres +DB_PASSWORD=your_secure_password_here +DB_NAME=payego +DB_PORT=5432 + +# Application Port +APP_PORT=8080 + +# JWT Configuration +JWT_SECRET=your_super_secret_key_must_be_at_least_32_characters_long +JWT_EXPIRATION_HOURS=2 +ISSUER=payego-api +AUDIENCE=payego-client + +# Payment Providers +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +PAYSTACK_SECRET_KEY=sk_test_... +PAYSTACK_WEBHOOK_SECRET=whsec_... +PAYPAL_CLIENT_ID=... +PAYPAL_SECRET=... + +# Application Settings +RUST_LOG=info +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +``` + +### 2. Build and Run + +Start all services: +```bash +docker-compose up --build -d +``` + +The `-d` flag runs containers in detached mode (background). + +### 3. Verify Deployment + +Check that all services are running: +```bash +docker-compose ps +``` + +You should see 4 services running: +- `payego-app-1` (Backend API) +- `payego-db-1` (PostgreSQL) +- `payego-prometheus-1` (Metrics) +- `payego-grafana-1` (Dashboards) + +--- + +## ๐ŸŒ Service Access + +Once deployed, access the services at: + +| Service | URL | Description | +|---------|-----|-------------| +| **Backend API** | http://localhost:8080 | Main application API | +| **Swagger UI** | http://localhost:8080/swagger-ui/ | API documentation | +| **Database** | localhost:5432 | PostgreSQL (use DB client) | +| **Prometheus** | http://localhost:9090 | Metrics collection | +| **Grafana** | http://localhost:3000 | Metrics dashboards (admin/admin) | + +--- + +## ๐Ÿ—๏ธ Architecture + +### Multi-Stage Build + +The Dockerfile uses a **multi-stage build** for optimal image size: + +1. **Builder Stage** (`rust:1.81-slim-bullseye`): + - Installs build dependencies + - Compiles Diesel CLI + - Builds Rust application in release mode + - Uses dependency caching for faster rebuilds + +2. **Runtime Stage** (`debian:bullseye-slim`): + - Minimal runtime dependencies + - Non-privileged user (`appuser`) + - Automatic database migrations on startup + - ~200MB final image size + +### Startup Sequence + +When the container starts: +1. **Wait for Database**: Checks PostgreSQL readiness +2. **Run Migrations**: Applies all pending Diesel migrations +3. **Start Application**: Launches Payego API server + +--- + +## ๐Ÿ“ฆ Services Configuration + +### Backend (app) + +```yaml +ports: + - "8080:8080" # API server +environment: + - DATABASE_URL=postgres://user:pass@db:5432/payego + - JWT_SECRET=... + - STRIPE_SECRET_KEY=... + # ... other env vars +depends_on: + - db +``` + +**Key Features:** +- Automatic database migrations +- Health checks via `pg_isready` +- Restart policy: `always` +- Custom DNS (8.8.8.8, 8.8.4.4) + +### Database (db) + +```yaml +image: postgres:15-alpine +ports: + - "5432:5432" +volumes: + - db_data:/var/lib/postgresql/data +``` + +**Key Features:** +- PostgreSQL 15 Alpine (lightweight) +- Persistent volume for data +- Automatic initialization + +### Monitoring Stack + +**Prometheus:** +- Collects metrics from Payego API +- Configuration: `prometheus.yml` +- Port: 9090 + +**Grafana:** +- Visualizes Prometheus metrics +- Default credentials: `admin/admin` +- Port: 3000 + +--- + +## ๐Ÿ”ง Common Operations + +### View Logs + +**All services:** +```bash +docker-compose logs -f +``` + +**Specific service:** +```bash +docker-compose logs -f app +docker-compose logs -f db +``` + +**Last 100 lines:** +```bash +docker-compose logs --tail=100 app +``` + +### Restart Services + +**All services:** +```bash +docker-compose restart +``` + +**Specific service:** +```bash +docker-compose restart app +``` + +### Stop Services + +```bash +docker-compose down +``` + +**Stop and remove volumes (โš ๏ธ deletes database data):** +```bash +docker-compose down -v +``` + +### Database Access + +**Connect to PostgreSQL:** +```bash +docker-compose exec db psql -U postgres -d payego +``` + +**Run SQL query:** +```bash +docker-compose exec db psql -U postgres -d payego -c "SELECT * FROM users LIMIT 5;" +``` + +### Run Migrations Manually + +If you need to run migrations without restarting: +```bash +docker-compose exec app diesel migration run +``` + +### Execute Commands in Container + +```bash +docker-compose exec app /bin/sh +``` + +--- + +## ๐Ÿ› Troubleshooting + +### Container Won't Start + +**Check logs:** +```bash +docker-compose logs app +``` + +**Common issues:** +- Missing environment variables โ†’ Check `.env` file +- Database not ready โ†’ Wait for `db` service to be healthy +- Port already in use โ†’ Change `APP_PORT` in `.env` + +### Database Connection Errors + +**Verify database is running:** +```bash +docker-compose ps db +``` + +**Check database logs:** +```bash +docker-compose logs db +``` + +**Test connection:** +```bash +docker-compose exec app pg_isready -h db -p 5432 +``` + +### Migration Failures + +**View migration status:** +```bash +docker-compose exec app diesel migration list +``` + +**Revert last migration:** +```bash +docker-compose exec app diesel migration revert +``` + +**Reset database (โš ๏ธ destructive):** +```bash +docker-compose down -v +docker-compose up -d +``` + +### Application Crashes + +**Check application logs:** +```bash +docker-compose logs --tail=200 app +``` + +**Common causes:** +- Invalid JWT_SECRET (must be 32+ characters) +- Missing payment provider credentials +- Database schema mismatch + +### Port Conflicts + +If port 8080 is already in use, change it in `.env`: +```env +APP_PORT=8081 +``` + +Then restart: +```bash +docker-compose down +docker-compose up -d +``` + +--- + +## ๐Ÿšข Production Deployment + +### Environment Variables + +For production, ensure you: +1. Use strong, unique passwords +2. Set `RUST_LOG=warn` or `error` (not `debug`) +3. Configure proper `CORS_ORIGINS` +4. Use production payment provider keys +5. Set secure `JWT_SECRET` (32+ random characters) + +### Security Considerations + +**Database:** +- Change default PostgreSQL password +- Restrict database port exposure (remove from `ports` if not needed externally) +- Use encrypted connections + +**Application:** +- Run behind reverse proxy (nginx/Traefik) +- Enable HTTPS/TLS +- Configure rate limiting +- Set up firewall rules + +### Scaling + +**Horizontal scaling:** +```bash +docker-compose up -d --scale app=3 +``` + +**Note:** Requires load balancer configuration. + +### Backup Database + +**Create backup:** +```bash +docker-compose exec db pg_dump -U postgres payego > backup.sql +``` + +**Restore backup:** +```bash +cat backup.sql | docker-compose exec -T db psql -U postgres payego +``` + +--- + +## ๐ŸŒ Cloud Deployment + +### Building for Different Platforms + +If deploying to cloud with different CPU architecture (e.g., Mac M1 โ†’ AMD64 cloud): + +```bash +docker build --platform=linux/amd64 -t payego:latest . +``` + +### Push to Registry + +**Tag image:** +```bash +docker tag payego:latest your-registry.com/payego:latest +``` + +**Push to registry:** +```bash +docker push your-registry.com/payego:latest +``` + +### Example: AWS ECS + +1. Push image to Amazon ECR +2. Create task definition with environment variables +3. Configure RDS PostgreSQL instance +4. Set up Application Load Balancer +5. Deploy ECS service + +### Example: Google Cloud Run + +1. Push image to Google Container Registry +2. Create Cloud SQL PostgreSQL instance +3. Deploy with environment variables +4. Configure Cloud Run service + +--- + +## ๐Ÿ“Š Monitoring + +### Prometheus Metrics + +Access Prometheus at http://localhost:9090 + +**Available metrics:** +- HTTP request duration +- Request count by endpoint +- Database connection pool stats +- System resource usage + +### Grafana Dashboards + +Access Grafana at http://localhost:3000 + +**Default credentials:** `admin/admin` + +**Setup:** +1. Add Prometheus data source (http://prometheus:9090) +2. Import dashboards from `grafana/provisioning/` +3. Create custom dashboards as needed + +--- + +## ๐Ÿ”„ Updates and Maintenance + +### Update Application + +1. Pull latest code +2. Rebuild and restart: +```bash +git pull +docker-compose up --build -d +``` + +### Update Dependencies + +Rebuild with no cache: +```bash +docker-compose build --no-cache +docker-compose up -d +``` + +### Clean Up + +**Remove unused images:** +```bash +docker image prune -a +``` + +**Remove unused volumes:** +```bash +docker volume prune +``` + +--- + +## ๐Ÿ“š Additional Resources + +- [Docker's Rust Guide](https://docs.docker.com/language/rust/) +- [Docker Compose Documentation](https://docs.docker.com/compose/) +- [PostgreSQL Docker Image](https://hub.docker.com/_/postgres) +- [Diesel Migrations](https://diesel.rs/guides/getting-started.html) + +--- + +## ๐Ÿ’ก Tips + +1. **Development:** Use `docker-compose up` (without `-d`) to see logs in real-time +2. **Production:** Always use `-d` flag and monitor logs separately +3. **Debugging:** Use `docker-compose exec app /bin/sh` to inspect container +4. **Performance:** Monitor Grafana dashboards for bottlenecks +5. **Backups:** Schedule regular database backups in production + +--- + +## ๐Ÿ†˜ Getting Help + +If you encounter issues: +1. Check logs: `docker-compose logs app` +2. Verify environment variables in `.env` +3. Ensure all required services are running +4. Check [main README](README.md) for application-specific details +5. Review [Swagger UI](http://localhost:8080/swagger-ui/) for API documentation \ No newline at end of file diff --git a/README.md b/README.md index 02f1158..0932c07 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Payego ๐Ÿš€ +# Payego #RustAfricaHackathon > **A modern, secure, and scalable payment processing platform built with Rust (Axum) and React.** @@ -6,42 +6,74 @@ ![Rust](https://img.shields.io/badge/rust-v1.83+-orange.svg) ![React](https://img.shields.io/badge/react-v19.1+-blue.svg) ![Status](https://img.shields.io/badge/status-active-success.svg) +![Tests](https://img.shields.io/badge/tests-31%20passing-success.svg) ## ๐Ÿ“– Overview **Payego** is a comprehensive financial technology platform designed to simulate a modern payment system. It enables users to manage multi-currency wallets, process payments via major gateways (Stripe, PayPal, Paystack), and perform secure internal/external transfers. -Built with a philosophy of **"Safety First"**, Payego leverages Rust's memory safety and strong type system on the backend, ensuring distinct separation between Database Entities and API Contracts. The frontend provides a polished, responsive user experience. +Built with a philosophy of **"Safety First"**, Payego leverages Rust's memory safety and strong type system on the backend, ensuring distinct separation between Database Entities and API Contracts. The frontend provides a polished, responsive user experience with centralized error handling #RustAfricaHackathon --- ## โœจ Key Features ### ๐Ÿ” Enterprise-Grade Security -- **Secure Authentication**: JWT-based stateless auth with short-lived access tokens and refresh tokens. -- **Configuration Security**: Sensitive keys (API secrets, DB passwords) are wrapped in `secrecy::Secret` types to prevent memory leaks and accidental logging. -- **Rate Limiting**: Integrated `tower-governor` prevents API abuse and DDoS attacks. -- **Type Safety**: API Data Transfer Objects (DTOs) are strictly separated from Database Entities, preventing data leakage (e.g., password hashes). +- **Secure Authentication**: JWT-based stateless auth with short-lived access tokens and refresh tokens +- **Email Verification**: Secure email verification flow with expiring tokens and resend capability +- **Configuration Security**: Sensitive keys (API secrets, DB passwords) wrapped in `secrecy::Secret` types to prevent memory leaks and accidental logging +- **Rate Limiting**: Integrated `tower-governor` prevents API abuse and DDoS attacks +- **Type Safety**: API Data Transfer Objects (DTOs) strictly separated from Database Entities, preventing data leakage (e.g., password hashes) +- **Audit Logging**: Comprehensive audit trail for all user actions (login, registration, transfers, etc.) ### ๐Ÿ’ฐ Comprehensive Financial Tools -- **Multi-Currency Wallets**: Real-time support for **USD**, **EUR**, **GBP**, and **NGN**. +- **Multi-Currency Wallets**: Real-time support for **20+ currencies** including USD, EUR, GBP, NGN, CAD, AUD, CHF, JPY, CNY, and more +- **Currency Conversion**: Internal currency conversion with real-time exchange rates and 1% fee - **Global Payments**: - - **Stripe**: Secure credit card processing. - - **PayPal**: International secure checkout. - - **Paystack**: African market integration with NGAN support. -- **Bank Integration**: Verify bank accounts and process direct withdrawals. + - **Stripe**: Secure credit card processing with webhook support + - **PayPal**: International secure checkout + - **Paystack**: African market integration with NGN support and account verification +- **Bank Integration**: + - Verify Nigerian bank accounts via Paystack + - Process direct withdrawals to bank accounts + - Add/remove bank accounts with confirmation flow +- **Transfer Types**: + - **Internal**: Transfer between Payego users by username + - **External**: Transfer to external bank accounts + - **Idempotency**: Duplicate request prevention with idempotency keys ### โšก Performance & Observability -- **Structured Logging**: Production-ready JSON logs with unique `X-Request-ID` tracing for every request. -- **Async Core**: Built on `Tokio` and `Axum` for massive concurrency support. -- **Optimized Database**: PostgreSQL with `Diesel` ORM connection pooling (`r2d2`). +- **Structured Logging**: Production-ready JSON logs with unique `X-Request-ID` tracing for every request +- **Async Core**: Built on `Tokio` and `Axum` for massive concurrency support +- **Optimized Database**: PostgreSQL with `Diesel` ORM connection pooling (`r2d2`) +- **Comprehensive Testing**: 31 passing backend integration tests covering all major flows --- ## ๐Ÿ—๏ธ Architecture -### Backend (`/src`) -The backend follows a **Clean Architecture** pattern: +### Backend Structure + +The backend follows a **Clean Architecture** pattern with three main crates: + +``` +payego/ +โ”œโ”€โ”€ bin/payego/ # Main application binary +โ”‚ โ””โ”€โ”€ tests/ # Integration tests (31 tests) +โ”œโ”€โ”€ crates/ +โ”‚ โ”œโ”€โ”€ api/ # HTTP handlers & routing (payego-api) +โ”‚ โ”‚ โ””โ”€โ”€ src/handlers/ # API endpoint handlers +โ”‚ โ”œโ”€โ”€ core/ # Business logic (payego-core) +โ”‚ โ”‚ โ”œโ”€โ”€ src/services/ # Service layer (auth, payment, transfer, etc.) +โ”‚ โ”‚ โ””โ”€โ”€ src/clients/ # External API clients (Stripe, PayPal, Paystack) +โ”‚ โ””โ”€โ”€ primitives/ # Shared types (payego-primitives) +โ”‚ โ””โ”€โ”€ src/models/ # Database entities & DTOs +โ””โ”€โ”€ payego_ui/ # React frontend + โ””โ”€โ”€ src/ + โ”œโ”€โ”€ components/ # React components + โ”œโ”€โ”€ utils/ # Utilities (error handling, etc.) + โ””โ”€โ”€ api/ # API client +``` ```mermaid graph TD @@ -59,22 +91,24 @@ graph TD Services -->|External API| Stripe[Stripe] Services -->|External API| PayPal[PayPal] Services -->|External API| Paystack[Paystack] + Services -->|SMTP| Email[Email Service] Models -->|SQL| DB[(PostgreSQL)] ``` -- **Handlers** (`src/handlers`): Thin layer responsible only for HTTP request parsing and response formatting. -- **Services** (`src/services`): Contain all business logic (e.g., `TransferService`, `PaymentService`, `AuthService`). This layer is unit-testable. -- **Models** (`src/models`): - - `entities.rs`: Direct mappings to PostgreSQL tables. - - `dtos.rs`: User-facing API structures (Input/Output). - - `app_state.rs`: Thread-safe application state container. +**Key Components:** +- **API Crate** (`crates/api`): Thin HTTP layer with handlers for routing and request/response formatting +- **Core Crate** (`crates/core`): Business logic services (AuthService, TransferService, PaymentService, etc.) +- **Primitives Crate** (`crates/primitives`): Shared types, database entities, and DTOs ### Frontend (`/payego_ui`) + Modern React application built with **Vite**: -- **Components**: Modular, reusable UI elements tailored with TailwindCSS. -- **API Client**: Centralized `Axios` instance with automatic auth header injection and global error handling. -- **Testing**: Infrastructure set up with **Vitest** and **React Testing Library**. +- **Components**: Modular, reusable UI elements with TailwindCSS +- **Centralized Error Handling**: Custom error utility extracts user-friendly messages from API responses +- **API Client**: Axios instance with automatic auth header injection and 401 redirect +- **State Management**: React Query for server state, Context API for auth +- **Testing**: Vitest and React Testing Library infrastructure --- @@ -95,17 +129,37 @@ Modern React application built with **Vite**: ``` 2. **Environment Configuration:** - Create a `.env` file in the root directory: + Copy `.env.example` to `.env` and configure: ```env + # Database DATABASE_URL=postgres://user:password@localhost/payego + + # JWT Configuration JWT_SECRET=super_secret_key_must_be_32_chars_long JWT_EXPIRATION_HOURS=2 + REFRESH_TOKEN_EXPIRATION_DAYS=7 + + # Payment Providers STRIPE_SECRET_KEY=sk_test_... + STRIPE_WEBHOOK_SECRET=whsec_... PAYPAL_CLIENT_ID=... PAYPAL_SECRET=... PAYSTACK_SECRET_KEY=sk_test_... + PAYSTACK_PUBLIC_KEY=pk_test_... + + # Application URLs APP_URL=http://localhost:8080 + FRONTEND_URL=http://localhost:5173 CORS_ORIGINS=http://localhost:5173 + + # Email (Optional - uses mock in development) + SMTP_HOST=smtp.gmail.com + SMTP_PORT=587 + SMTP_USERNAME=your-email@gmail.com + SMTP_PASSWORD=your-app-password + SMTP_FROM=noreply@payego.com + + # Logging RUST_LOG=info ``` @@ -140,27 +194,83 @@ Modern React application built with **Vite**: ## ๐Ÿ“š API Documentation -Payego includes auto-generated **Swagger/OpenAPI** documentation. +Payego includes auto-generated **Swagger/OpenAPI** documentation with complete endpoint descriptions, request/response schemas, and status codes. + Once the server is running, visit: ๐Ÿ‘‰ **[http://localhost:8080/swagger-ui/](http://localhost:8080/swagger-ui/)** +### Quick API Reference + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/auth/register` | POST | Register new user | +| `/api/auth/login` | POST | Login with credentials | +| `/api/auth/refresh` | POST | Refresh access token | +| `/api/auth/verify-email` | POST | Verify email with token | +| `/api/auth/resend-verification` | POST | Resend verification email | +| `/api/user/me` | GET | Get current user info | +| `/api/wallet/top-up` | POST | Initiate payment (Stripe/PayPal) | +| `/api/wallet/transfer/internal` | POST | Transfer to Payego user | +| `/api/wallet/transfer/external` | POST | Transfer to bank account | +| `/api/wallet/convert` | POST | Convert between currencies | +| `/api/wallet/withdraw/:id` | POST | Withdraw to bank account | +| `/api/bank/add` | POST | Add bank account | +| `/api/bank/delete/:id` | DELETE | Remove bank account | +| `/api/bank/resolve` | GET | Verify bank account details | +| `/api/bank/user` | GET | List user's bank accounts | +| `/api/transactions/:id` | GET | Get transaction details | +| `/api/transactions/user` | GET | Get user transaction history | +| `/api/audit-logs` | GET | Get user audit logs | + --- ## ๐Ÿงช Testing -### Backend tests -Run unit tests for Services and Integration tests for Handlers: +### Backend Tests (31 passing) + +Run comprehensive integration tests: ```bash -cargo test +cargo test --workspace ``` -### Frontend tests +**Test Coverage:** +- โœ… Authentication & JWT (7 tests) +- โœ… Email Verification (2 tests) +- โœ… Audit Logging (2 tests) +- โœ… Banking Operations (4 tests) +- โœ… Currency Conversion (3 tests) +- โœ… Payments (1 test) +- โœ… Transactions (4 tests) +- โœ… Withdrawals (3 tests) +- โœ… Wallets (2 tests) +- โœ… Idempotency (multiple tests) +- โœ… Rate Limiting +- โœ… Error Handling + +### Frontend Tests + Run Vitest for the React application: ```bash cd payego_ui npm test ``` +**Current Coverage:** +- โœ… Error handler utility tests +- ๐Ÿšง Component tests (in progress) + +### Code Quality + +Check code quality with Clippy: +```bash +cargo clippy --workspace --tests -- -D warnings +``` + +Format code: +```bash +cargo fmt --all +``` + --- ## ๐Ÿณ Docker Deployment @@ -176,30 +286,84 @@ The simplest way to run Payego in production or development is using Docker Comp This command builds the optimized production image, starts the application, initializes the PostgreSQL database, and automatically runs all migrations. - **Backend**: `http://localhost:8081` (Internal 8080) +- **Frontend**: Build and serve with nginx - **Database**: `localhost:5434` (Internal 5432) -- **Metrics**: `http://localhost:8081/api/metrics` +- **Swagger UI**: `http://localhost:8081/swagger-ui/` --- -## ๐Ÿค Contributing +## ๐Ÿ”‘ Key Technical Decisions -1. Fork the repo. -2. Create a feature branch (`git checkout -b feature/NewThing`). -3. Commit changes (`git commit -m 'Add NewThing'`). -4. Push to branch (`git push origin feature/NewThing`). -5. Open a Pull Request. +### Idempotency +All financial operations (transfers, withdrawals, conversions) require an `idempotency_key` to prevent duplicate transactions. The system tracks processed keys and returns the original result for duplicate requests. + +### Email Verification +- New users receive verification emails with 24-hour expiring tokens +- Mock email client in development (logs to console) +- SMTP configuration optional for production + +### Error Handling +- Backend: Structured error types with proper HTTP status codes +- Frontend: Centralized error handler extracts user-friendly messages +- All errors logged with request IDs for debugging + +### Audit Logging +All sensitive operations (login, registration, transfers) are logged to the `audit_logs` table with: +- User ID +- Action type +- IP address +- Timestamp +- Additional metadata --- -## Built with โค๏ธ By +## ๐Ÿค Contributing -**Michael Dean Oyewole** +1. Fork the repo +2. Create a feature branch (`git checkout -b feature/NewThing`) +3. Commit changes (`git commit -m 'Add NewThing'`) +4. Push to branch (`git push origin feature/NewThing`) +5. Open a Pull Request + +**Development Guidelines:** +- Write tests for new features +- Run `cargo clippy` before committing +- Follow existing code style +- Update documentation as needed --- -## ๐Ÿ“ License +## ๐Ÿ› ๏ธ Tech Stack + +**Backend:** +- Rust 1.83+ +- Axum (web framework) +- Tokio (async runtime) +- Diesel (ORM) +- PostgreSQL (database) +- Tower (middleware) +- Utoipa (OpenAPI docs) + +**Frontend:** +- React 19.1+ +- TypeScript +- Vite (build tool) +- TailwindCSS (styling) +- React Query (data fetching) +- React Hook Form + Zod (forms & validation) +- Axios (HTTP client) + +**External Services:** +- Stripe (payments) +- PayPal (payments) +- Paystack (African payments & bank verification) -This project is licensed under the MIT License. +--- +## Built for #RustAfricaHackathon By **Michael Dean Oyewole** +--- + +## ๐Ÿ“ License +This project is licensed under the MIT License. diff --git a/bin/payego/src/lib.rs b/bin/payego/src/lib.rs index 4ed8d36..251b5c0 100644 --- a/bin/payego/src/lib.rs +++ b/bin/payego/src/lib.rs @@ -1,5 +1,3 @@ -// Library entry point for Payego -// This exposes modules for testing while keeping main.rs as the binary entry point mod observability; pub mod utility; @@ -17,36 +15,36 @@ use payego_primitives::models::app_config::AppConfig; use tracing::info; pub async fn run() -> Result<(), Report> { - // 1. Load environment variables + // 1. load environment variables load_env(); - // 2. Initialize logging first (so we can log everything else) + // 2. initialize logging first (so we can log everything else) setup_logging(); info!("Starting Payego application..."); - // 3. Load configuration + // 3. load configuration let config = AppConfig::from_env()?; - // 4. Create database connection pool + // 4. create database connection pool let pool = create_db_pool()?; - // 5. Build application state + // 5. build application state let state = AppState::new(pool, config)?; - // 6. Perform one-time system initialization + // 6. perform one-time system initialization initialize_system(&state).await; - // 7. Start background maintenance tasks + // 7. start background maintenance tasks spawn_background_tasks(state.clone()); - // 8. Initialize metrics - let (metric_layer, metric_handle) = crate::observability::metrics::setup_metrics(); + // 8. initialize metrics + let (metric_layer, metric_handle) = observability::metrics::setup_metrics(); - // 9. Build Axum router + // 9. build axum router let app = build_router(state.clone(), metric_layer, metric_handle)?; - // 10. Start HTTP server + // 10. start HTTP server serve(app).await?; info!("Payego application shut down gracefully"); diff --git a/bin/payego/src/utility/tasks.rs b/bin/payego/src/utility/tasks.rs index 7d89bd4..3c92ea1 100644 --- a/bin/payego/src/utility/tasks.rs +++ b/bin/payego/src/utility/tasks.rs @@ -1,5 +1,3 @@ -use std::env; -// Background utility for Payego use axum::extract::State; use axum::Router; use axum_prometheus::{metrics_exporter_prometheus::PrometheusHandle, PrometheusMetricLayer}; @@ -7,6 +5,7 @@ use eyre::Report; use http::HeaderValue; use payego_api::handlers::initialize_banks::initialize_banks; use payego_core::app_state::AppState; +use std::env; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; diff --git a/bin/payego/tests/bank_service_test.rs b/bin/payego/tests/bank_service_test.rs index 2ccbd31..45f2b65 100644 --- a/bin/payego/tests/bank_service_test.rs +++ b/bin/payego/tests/bank_service_test.rs @@ -15,11 +15,9 @@ mod common; #[tokio::test] #[serial] async fn test_add_bank_account_success() { - // 1. Setup WireMock let mock_server = MockServer::start().await; let base_url = mock_server.uri(); - // Mock Paystack Resolve Account Mock::given(method("GET")) .and(path("/bank/resolve")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ diff --git a/crates/api/src/handlers/audit_logs.rs b/crates/api/src/handlers/audit_logs.rs index 15be159..5463bdf 100644 --- a/crates/api/src/handlers/audit_logs.rs +++ b/crates/api/src/handlers/audit_logs.rs @@ -3,15 +3,9 @@ use payego_core::app_state::AppState; use payego_core::repositories::audit_repository::AuditLogRepository; use payego_core::security::Claims; use payego_primitives::error::ApiError; -use serde::Deserialize; +use payego_primitives::models::AuditLogQuery; use std::sync::Arc; -#[derive(Deserialize)] -pub struct AuditLogQuery { - pub page: Option, - pub size: Option, -} - pub async fn get_user_audit_logs( State(state): State>, Extension(claims): Extension, diff --git a/crates/api/src/handlers/current_user.rs b/crates/api/src/handlers/current_user.rs index d04455b..7964459 100755 --- a/crates/api/src/handlers/current_user.rs +++ b/crates/api/src/handlers/current_user.rs @@ -10,7 +10,7 @@ use std::sync::Arc; #[utoipa::path( get, path = "/api/user/current", - tag = "User", + tag = "Authentication", summary = "Get current authenticated user details", description = "Retrieves profile information for the currently authenticated user based on the JWT bearer token. \ Returns user data including ID, email, name, phone, and account status. \ diff --git a/crates/api/src/handlers/exchange_rate.rs b/crates/api/src/handlers/exchange_rate.rs index 34a968f..7c121c9 100644 --- a/crates/api/src/handlers/exchange_rate.rs +++ b/crates/api/src/handlers/exchange_rate.rs @@ -5,22 +5,8 @@ use axum::{ use payego_core::services::conversion_service::{ApiError, AppState, ConversionService}; use payego_primitives::error::ApiErrorResponse; use payego_primitives::models::enum_types::CurrencyCode; -use serde::{Deserialize, Serialize}; +use payego_primitives::models::{ExchangeQuery, ExchangeResponse}; use std::sync::Arc; -use utoipa::ToSchema; - -#[derive(Debug, Deserialize, ToSchema)] -pub struct ExchangeRateQuery { - pub from: CurrencyCode, - pub to: CurrencyCode, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ExchangeRateResponse { - pub from: String, - pub to: String, - pub rate: f64, -} #[utoipa::path( get, @@ -34,18 +20,18 @@ pub struct ExchangeRateResponse { ("to" = CurrencyCode, Query, description = "Target currency code"), ), responses( - (status = 200, description = "Exchange rate retrieved successfully", body = ExchangeRateResponse), + (status = 200, description = "Exchange rate retrieved successfully", body = ExchangeResponse), (status = 400, description = "Bad request - invalid currency codes", body = ApiErrorResponse), (status = 500, description = "Internal server error - failed to fetch exchange rate", body = ApiErrorResponse), ), )] pub async fn get_exchange_rate( State(state): State>, - Query(params): Query, -) -> Result, ApiError> { + Query(params): Query, +) -> Result, ApiError> { let rate = ConversionService::get_exchange_rate(&state, params.from, params.to).await?; - Ok(Json(ExchangeRateResponse { + Ok(Json(ExchangeResponse { from: params.from.to_string(), to: params.to.to_string(), rate, diff --git a/crates/api/src/handlers/login.rs b/crates/api/src/handlers/login.rs index 9ef95f1..4e1bb0c 100755 --- a/crates/api/src/handlers/login.rs +++ b/crates/api/src/handlers/login.rs @@ -4,7 +4,6 @@ use payego_core::services::auth_service::login::{ }; use payego_primitives::error::ApiErrorResponse; use std::sync::Arc; -use tracing::info; #[utoipa::path( post, @@ -35,7 +34,6 @@ pub async fn login( State(state): State>, Json(payload): Json, ) -> Result, ApiError> { - info!("email: {}, password: {}", payload.email, payload.password); let response = LoginService::login(&state, payload.normalize()).await?; Ok(Json(response)) } diff --git a/crates/api/src/handlers/register.rs b/crates/api/src/handlers/register.rs index 6b94c22..3e37698 100755 --- a/crates/api/src/handlers/register.rs +++ b/crates/api/src/handlers/register.rs @@ -47,7 +47,7 @@ pub async fn register( ApiError::Validation(e) })?; - let response = RegisterService::register(&state, payload).await?; + let response = RegisterService::register(&state, payload.normalize()).await?; Ok((StatusCode::CREATED, Json(response))) } diff --git a/crates/api/src/handlers/resolve_user.rs b/crates/api/src/handlers/resolve_user.rs index 1a5c68b..ce803d6 100644 --- a/crates/api/src/handlers/resolve_user.rs +++ b/crates/api/src/handlers/resolve_user.rs @@ -10,7 +10,7 @@ use std::sync::Arc; #[utoipa::path( get, path = "/api/users/resolve", - tag = "Users", + tag = "Authentication", params( ("identifier" = String, Query, description = "Username or email") ), diff --git a/crates/api/src/handlers/verify_email.rs b/crates/api/src/handlers/verify_email.rs index 9047c19..a4e7511 100644 --- a/crates/api/src/handlers/verify_email.rs +++ b/crates/api/src/handlers/verify_email.rs @@ -22,6 +22,10 @@ pub async fn verify_email( "message": "Email verified successfully" }))) } +// pub struct VerifyEmailResponse { +// pub status: String, +// pub message: String +// } pub async fn resend_verification( State(state): State>, diff --git a/crates/core/src/clients/email.rs b/crates/core/src/clients/email.rs index d814141..4dee065 100644 --- a/crates/core/src/clients/email.rs +++ b/crates/core/src/clients/email.rs @@ -29,13 +29,17 @@ impl EmailClient { let transport = if let (Some(host), Some(user), Some(pass)) = (smtp_host, smtp_user, smtp_pass) { let creds = Credentials::new(user, pass); - Some( - SmtpTransport::relay(&host) - .unwrap() - .credentials(creds) - .port(smtp_port) - .build(), - ) + match SmtpTransport::starttls_relay(&host) { + Ok(builder) => Some(builder.credentials(creds).port(smtp_port).build()), + Err(e) => { + tracing::error!( + "Failed to initialize STARTTLS relay for host {}: {}", + host, + e + ); + None + } + } } else { tracing::warn!("SMTP configuration missing, email client running in mock mode"); None @@ -59,6 +63,7 @@ impl EmailClient { .parse() .map_err(|e| ApiError::Internal(format!("Invalid recipient email: {}", e)))?) .subject(subject) + .header(lettre::message::header::ContentType::TEXT_HTML) .body(body.to_string()) .map_err(|e| ApiError::Internal(format!("Failed to build email: {}", e)))?; diff --git a/crates/core/src/services/auth_service/verification.rs b/crates/core/src/services/auth_service/verification.rs index d20d589..40f10e6 100644 --- a/crates/core/src/services/auth_service/verification.rs +++ b/crates/core/src/services/auth_service/verification.rs @@ -25,6 +25,7 @@ impl VerificationService { let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::hours(24); VerificationRepository::delete_for_user(&mut conn, user_id)?; + VerificationRepository::create( &mut conn, NewVerificationToken { @@ -34,10 +35,26 @@ impl VerificationService { }, )?; + let app_url = + std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".to_string()); + let verification_url = format!("{}/verify-email?token={}", app_url, token); + let subject = "Verify your email - Payego"; let body = format!( - "Please verify your email by clicking here: /verify-email?token={}", - token + r#" +
+

Welcome to Payego!

+

Please verify your email address to get started managing your finances.

+ +

If the button doesn't work, copy and paste this link into your browser:

+

{0}

+
+

This link will expire in 24 hours.

+
+ "#, + verification_url ); state.email.send_email(email, subject, &body).await?; @@ -47,6 +64,7 @@ impl VerificationService { pub async fn verify_email(state: &AppState, token: &str) -> Result<(), ApiError> { let token_hash = Self::hash_token(token); + let mut conn = state .db .get() diff --git a/crates/core/src/services/payment_service.rs b/crates/core/src/services/payment_service.rs index e710979..2d718c0 100644 --- a/crates/core/src/services/payment_service.rs +++ b/crates/core/src/services/payment_service.rs @@ -55,11 +55,9 @@ impl PaymentService { let response = match req.provider { PaymentProvider::Stripe => { - let success_url = format!( - "{}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", - state.config.app_url - ); - let cancel_url = format!("{}/checkout/cancel", state.config.app_url); + let success_url = + format!("{}/success?transaction_id={}", state.config.app_url, tx_ref); + let cancel_url = format!("{}/top-up", state.config.app_url); let session = state .stripe diff --git a/crates/core/src/services/paypal_service.rs b/crates/core/src/services/paypal_service.rs index 038e14e..f558123 100644 --- a/crates/core/src/services/paypal_service.rs +++ b/crates/core/src/services/paypal_service.rs @@ -18,6 +18,7 @@ pub use payego_primitives::{ use reqwest::Url; use secrecy::ExposeSecret; +use serde_json; use std::str::FromStr; use std::time::Duration; use tracing::log::error; @@ -66,10 +67,12 @@ impl PayPalService { return Err(ApiError::Payment("PayPal authentication failed".into())); } - let token = resp - .json::() - .await - .map_err(|_| ApiError::Payment("Invalid PayPal token response".into()))?; + let token = resp.json::().await.map_err(|e| { + tracing::error!("Failed to parse PayPal token response: {}", e); + ApiError::Payment("Invalid PayPal token response".into()) + })?; + + tracing::info!("Successfully retrieved PayPal access token"); Ok(token.access_token) } @@ -118,6 +121,12 @@ impl PayPalService { order_id: String, transaction_ref: Uuid, ) -> Result { + tracing::info!( + "Starting capture_order: order_id={}, transaction_ref={}", + order_id, + transaction_ref + ); + let mut conn = state.db.get().map_err(|e| { error!("Database error: {}", e); ApiError::DatabaseConnection(e.to_string()) @@ -125,10 +134,20 @@ impl PayPalService { let transaction = TransactionRepository::find_by_id_or_reference(&mut conn, transaction_ref)? - .ok_or_else(|| ApiError::Payment("Transaction not found".into()))?; + .ok_or_else(|| { + tracing::error!("Transaction not found: {}", transaction_ref); + ApiError::Payment("Transaction not found".into()) + })?; + + tracing::info!( + "Found transaction: id={}, state={:?}", + transaction.id, + transaction.txn_state + ); // โ”€โ”€ Idempotency if transaction.txn_state == PaymentState::Completed { + tracing::info!("Transaction already completed, returning idempotent response"); return Ok(CaptureResponse { status: PaymentState::Completed, transaction_id: transaction_ref, @@ -137,16 +156,27 @@ impl PayPalService { } if transaction.txn_state != PaymentState::Pending { + tracing::error!( + "Invalid transaction state for capture: {:?}", + transaction.txn_state + ); return Err(ApiError::Payment("Invalid transaction state".into())); } let capture = Self::paypal_capture_api(state, &order_id).await?; if capture.currency != transaction.currency { + tracing::error!( + "Currency mismatch: expected {:?}, got {:?}", + transaction.currency, + capture.currency + ); return Err(ApiError::Payment("Currency mismatch".into())); } conn.transaction::<_, ApiError, _>(|conn| { + tracing::info!("Starting database transaction for capture"); + // โ”€โ”€ Update transaction TransactionRepository::update_status_and_provider_ref( conn, @@ -154,13 +184,16 @@ impl PayPalService { PaymentState::Completed, Some(capture.capture_id.clone()), )?; + tracing::info!("Transaction updated to Completed"); - // โ”€โ”€ Lock wallet - let wallet = WalletRepository::find_by_user_and_currency_with_lock( + // โ”€โ”€ Lock wallet or create if needed + // Use create_if_not_exists to avoid "Wallet not found" error + let wallet = WalletRepository::create_if_not_exists( conn, transaction.user_id, transaction.currency, )?; + tracing::info!("Wallet retrieved/created: {}", wallet.id); // โ”€โ”€ Ledger entry WalletRepository::add_ledger_entry( @@ -171,9 +204,11 @@ impl PayPalService { amount: transaction.amount, }, )?; + tracing::info!("Ledger entry added"); // โ”€โ”€ Update balance WalletRepository::credit(conn, wallet.id, transaction.amount)?; + tracing::info!("Wallet credited"); Ok(()) })?; @@ -189,7 +224,7 @@ impl PayPalService { state: &AppState, order_id: &str, ) -> Result { - let client = state.http_client.clone(); + tracing::info!("Retrieving PayPal access token..."); let token = Self::get_access_token(state).await?; let base = Url::parse(&state.config.paypal_details.paypal_api_url) @@ -199,35 +234,65 @@ impl PayPalService { .join(&format!("v2/checkout/orders/{}/capture", order_id)) .map_err(|_| ApiError::Internal("Invalid PayPal capture URL".into()))?; - let resp = client + let request = state + .http_client .post(url) .bearer_auth(token) .header("Content-Type", "application/json") - .send() - .await - .map_err(|e| { - tracing::error!(error = %e, "PayPal capture request failed"); - ApiError::Payment("Failed to reach PayPal".into()) - })? - .error_for_status() - .map_err(|e| { - tracing::warn!(error = %e, "PayPal capture rejected"); - ApiError::Payment("PayPal capture rejected".into()) - })?; + .header("PayPal-Request-Id", Uuid::new_v4().to_string()); + + let resp = request.send().await.map_err(|e| { + tracing::error!(error = %e, "PayPal capture request failed"); + ApiError::Payment("Failed to reach PayPal".into()) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "Failed to read response body".to_string()); + tracing::error!(status = %status, body = %body, "PayPal capture rejected"); + return Err(ApiError::Payment(format!( + "PayPal capture rejected: {}", + body + ))); + } + + // Read raw body first to debug schema mismatches + let body_text = resp.text().await.map_err(|e| { + tracing::error!(error = %e, "Failed to read response text"); + ApiError::Payment("Failed to read PayPal response".into()) + })?; - let body: PayPalCaptureResponse = resp.json().await.map_err(|e| { - tracing::error!(error = %e, "Invalid PayPal capture response"); - ApiError::Payment("Invalid PayPal capture response".into()) + let body: PayPalCaptureResponse = serde_json::from_str(&body_text).map_err(|e| { + tracing::error!(error = %e, "Invalid PayPal capture response schema"); + ApiError::Payment(format!("Invalid PayPal capture response: {}", e)) })?; + tracing::info!("Deserialization successful: {:?}", body); + let capture = body .purchase_units .first() .and_then(|pu| pu.payments.captures.first()) - .ok_or_else(|| ApiError::Payment("Missing PayPal capture data".into()))?; + .ok_or_else(|| { + tracing::error!("Missing capture data in PayPal response: {:?}", body); + ApiError::Payment("Missing PayPal capture data".into()) + })?; + + tracing::info!("Capture extracted: {:?}", capture); + + let currency = CurrencyCode::from_str(&capture.amount.currency_code).map_err(|e| { + tracing::error!( + "Unsupported currency code '{}': {}", + capture.amount.currency_code, + e + ); + ApiError::Payment("Unsupported currency".into()) + })?; - let currency = CurrencyCode::from_str(&capture.amount.currency_code) - .map_err(|_| ApiError::Payment("Unsupported currency".into()))?; + tracing::info!("Currency parsed: {:?}", currency); Ok(PaypalCapture { capture_id: capture.id.clone(), diff --git a/crates/primitives/src/models/dtos/auth_dto.rs b/crates/primitives/src/models/dtos/auth_dto.rs index ddc20ba..87ced23 100644 --- a/crates/primitives/src/models/dtos/auth_dto.rs +++ b/crates/primitives/src/models/dtos/auth_dto.rs @@ -114,3 +114,9 @@ pub struct HealthStatus { pub status: String, pub message: String, } + +#[derive(Deserialize)] +pub struct AuditLogQuery { + pub page: Option, + pub size: Option, +} diff --git a/crates/primitives/src/models/dtos/providers/paypal.rs b/crates/primitives/src/models/dtos/providers/paypal.rs index aec03e1..9b676ef 100644 --- a/crates/primitives/src/models/dtos/providers/paypal.rs +++ b/crates/primitives/src/models/dtos/providers/paypal.rs @@ -45,28 +45,28 @@ pub struct PayPalOrderResp { pub links: Vec, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct CaptureAmount { pub currency_code: String, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct Capture { pub id: String, pub amount: CaptureAmount, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct Payments { pub captures: Vec, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct PurchaseUnit { pub payments: Payments, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct PayPalCaptureResponse { pub purchase_units: Vec, } diff --git a/crates/primitives/src/models/dtos/transaction_dto.rs b/crates/primitives/src/models/dtos/transaction_dto.rs index 6148ff5..71bc807 100644 --- a/crates/primitives/src/models/dtos/transaction_dto.rs +++ b/crates/primitives/src/models/dtos/transaction_dto.rs @@ -2,7 +2,7 @@ use crate::models::enum_types::{CurrencyCode, PaymentState, TransactionIntent}; use crate::models::transaction::Transaction; use chrono::{DateTime, Utc}; use diesel::Queryable; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; @@ -46,3 +46,16 @@ impl From for TransactionResponse { } } } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ExchangeQuery { + pub from: CurrencyCode, + pub to: CurrencyCode, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ExchangeResponse { + pub from: String, + pub to: String, + pub rate: f64, +} diff --git a/payego_ui/src/components/LoginForm.tsx b/payego_ui/src/components/LoginForm.tsx index 56e8627..e7edeff 100644 --- a/payego_ui/src/components/LoginForm.tsx +++ b/payego_ui/src/components/LoginForm.tsx @@ -38,7 +38,7 @@ const LoginForm: React.FC = () => { setError(null); try { const response = await authApi.login(data.email, data.password); - login(response.data.token); + login(response.data.token, rememberMe); navigate('/dashboard'); } catch (err: any) { setError(getErrorMessage(err)); diff --git a/payego_ui/src/components/PayPalPayment.tsx b/payego_ui/src/components/PayPalPayment.tsx index 883a5a3..9b6587b 100644 --- a/payego_ui/src/components/PayPalPayment.tsx +++ b/payego_ui/src/components/PayPalPayment.tsx @@ -25,7 +25,7 @@ const PayPalPayment: React.FC = ({ paymentId, transactionId, PP

Pay with PayPal

-

Amount: {amount} {currency}

+

Amount: {amount/100} {currency}

void; + login: (token: string, rememberMe?: boolean) => void; logout: () => void; refreshUser: () => Promise; } @@ -17,11 +17,11 @@ const AuthContext = createContext(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const queryClient = useQueryClient(); const [user, setUser] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem('jwt_token')); + const [isAuthenticated, setIsAuthenticated] = useState(!!(localStorage.getItem('jwt_token') || sessionStorage.getItem('jwt_token'))); const [isLoading, setIsLoading] = useState(true); const refreshUser = async () => { - const token = localStorage.getItem('jwt_token'); + const token = localStorage.getItem('jwt_token') || sessionStorage.getItem('jwt_token'); if (!token) { setUser(null); setIsAuthenticated(false); @@ -36,6 +36,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } catch (error) { console.error('Failed to fetch user:', error); localStorage.removeItem('jwt_token'); + sessionStorage.removeItem('jwt_token'); setUser(null); setIsAuthenticated(false); } finally { @@ -47,14 +48,21 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => refreshUser(); }, []); - const login = (token: string) => { - localStorage.setItem('jwt_token', token); + const login = (token: string, rememberMe: boolean = false) => { + if (rememberMe) { + localStorage.setItem('jwt_token', token); + sessionStorage.removeItem('jwt_token'); + } else { + sessionStorage.setItem('jwt_token', token); + localStorage.removeItem('jwt_token'); + } setIsAuthenticated(true); refreshUser(); }; const logout = () => { localStorage.removeItem('jwt_token'); + sessionStorage.removeItem('jwt_token'); setUser(null); setIsAuthenticated(false); queryClient.clear(); diff --git a/payego_ui/src/pages/VerifyEmail.tsx b/payego_ui/src/pages/VerifyEmail.tsx index f08b1d9..135c457 100644 --- a/payego_ui/src/pages/VerifyEmail.tsx +++ b/payego_ui/src/pages/VerifyEmail.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { authApi } from '../api/auth'; import { getErrorMessage } from '../utils/errorHandler'; @@ -9,15 +9,16 @@ const VerifyEmail: React.FC = () => { const [error, setError] = useState(null); const navigate = useNavigate(); const token = searchParams.get('token'); + const hasStartedVerification = useRef(false); useEffect(() => { const verify = async () => { - if (!token) { - setStatus('error'); - setError('Missing verification token'); + if (!token || hasStartedVerification.current) { return; } + hasStartedVerification.current = true; + try { await authApi.verifyEmail(token); setStatus('success'); diff --git a/payego_ui/src/utils/__tests__/errorHandler.test.ts b/payego_ui/src/utils/__tests__/errorHandler.test.ts new file mode 100644 index 0000000..d1739e3 --- /dev/null +++ b/payego_ui/src/utils/__tests__/errorHandler.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from 'vitest'; +import { getErrorMessage, isNetworkError, isAuthError, isValidationError } from '../errorHandler'; + +describe('getErrorMessage', () => { + describe('API error responses', () => { + it('extracts message from response.data.message', () => { + const error = { + response: { + data: { message: 'Invalid credentials' }, + status: 401, + }, + }; + expect(getErrorMessage(error)).toBe('Invalid credentials'); + }); + + it('extracts message from response.data.error', () => { + const error = { + response: { + data: { error: 'User not found' }, + status: 404, + }, + }; + expect(getErrorMessage(error)).toBe('User not found'); + }); + + it('handles validation error arrays with message objects', () => { + const error = { + response: { + data: { + errors: [ + { message: 'Email is required', field: 'email' }, + { message: 'Password too short', field: 'password' }, + ], + }, + status: 422, + }, + }; + expect(getErrorMessage(error)).toBe('Email is required, Password too short'); + }); + + it('handles validation error arrays with strings', () => { + const error = { + response: { + data: { + errors: ['Email is required', 'Password too short'], + }, + status: 422, + }, + }; + expect(getErrorMessage(error)).toBe('Email is required, Password too short'); + }); + + it('handles mixed validation error arrays', () => { + const error = { + response: { + data: { + errors: [ + { message: 'Email is required' }, + 'Password too short', + { field: 'username' }, // No message + ], + }, + status: 422, + }, + }; + expect(getErrorMessage(error)).toBe('Email is required, Password too short, Validation error'); + }); + }); + + describe('HTTP status code fallbacks', () => { + it('returns appropriate message for 400 Bad Request', () => { + const error = { + response: { + data: {}, + status: 400, + }, + }; + expect(getErrorMessage(error)).toBe('Invalid request. Please check your input.'); + }); + + it('returns appropriate message for 401 Unauthorized', () => { + const error = { + response: { + data: {}, + status: 401, + }, + }; + expect(getErrorMessage(error)).toBe('Session expired. Please log in again.'); + }); + + it('returns appropriate message for 403 Forbidden', () => { + const error = { + response: { + data: {}, + status: 403, + }, + }; + expect(getErrorMessage(error)).toBe("You don't have permission to perform this action."); + }); + + it('returns appropriate message for 404 Not Found', () => { + const error = { + response: { + data: {}, + status: 404, + }, + }; + expect(getErrorMessage(error)).toBe('Resource not found.'); + }); + + it('returns appropriate message for 409 Conflict', () => { + const error = { + response: { + data: {}, + status: 409, + }, + }; + expect(getErrorMessage(error)).toBe('This action conflicts with existing data.'); + }); + + it('returns appropriate message for 422 Unprocessable Entity', () => { + const error = { + response: { + data: {}, + status: 422, + }, + }; + expect(getErrorMessage(error)).toBe('Validation failed. Please check your input.'); + }); + + it('returns appropriate message for 429 Too Many Requests', () => { + const error = { + response: { + data: {}, + status: 429, + }, + }; + expect(getErrorMessage(error)).toBe('Too many requests. Please try again later.'); + }); + + it('returns appropriate message for 500 Internal Server Error', () => { + const error = { + response: { + data: {}, + status: 500, + }, + }; + expect(getErrorMessage(error)).toBe('Server error. Please try again later.'); + }); + + it('returns appropriate message for 502 Bad Gateway', () => { + const error = { + response: { + data: {}, + status: 502, + }, + }; + expect(getErrorMessage(error)).toBe('Server error. Please try again later.'); + }); + + it('returns appropriate message for 503 Service Unavailable', () => { + const error = { + response: { + data: {}, + status: 503, + }, + }; + expect(getErrorMessage(error)).toBe('Server error. Please try again later.'); + }); + + it('returns statusText for unknown status codes', () => { + const error = { + response: { + data: {}, + status: 418, + statusText: "I'm a teapot", + }, + }; + expect(getErrorMessage(error)).toBe("I'm a teapot"); + }); + + it('returns generic message when statusText is missing', () => { + const error = { + response: { + data: {}, + status: 418, + }, + }; + expect(getErrorMessage(error)).toBe('Request failed'); + }); + }); + + describe('network errors', () => { + it('handles network errors (no response)', () => { + const error = { + request: {}, + }; + expect(getErrorMessage(error)).toBe('Network error. Please check your internet connection.'); + }); + }); + + describe('other errors', () => { + it('extracts message from error.message', () => { + const error = { + message: 'Something went wrong', + }; + expect(getErrorMessage(error)).toBe('Something went wrong'); + }); + + it('returns generic message for unknown errors', () => { + const error = {}; + expect(getErrorMessage(error)).toBe('An unexpected error occurred. Please try again.'); + }); + + it('handles null/undefined errors', () => { + expect(getErrorMessage(null)).toBe('An unexpected error occurred. Please try again.'); + expect(getErrorMessage(undefined)).toBe('An unexpected error occurred. Please try again.'); + }); + }); +}); + +describe('isNetworkError', () => { + it('returns true for network errors', () => { + const error = { + request: {}, + }; + expect(isNetworkError(error)).toBe(true); + }); + + it('returns false for API errors with response', () => { + const error = { + request: {}, + response: { + status: 500, + }, + }; + expect(isNetworkError(error)).toBe(false); + }); + + it('returns false for other errors', () => { + const error = { + message: 'Something went wrong', + }; + expect(isNetworkError(error)).toBe(false); + }); +}); + +describe('isAuthError', () => { + it('returns true for 401 errors', () => { + const error = { + response: { + status: 401, + }, + }; + expect(isAuthError(error)).toBe(true); + }); + + it('returns false for other status codes', () => { + const error = { + response: { + status: 400, + }, + }; + expect(isAuthError(error)).toBe(false); + }); + + it('returns false for network errors', () => { + const error = { + request: {}, + }; + expect(isAuthError(error)).toBe(false); + }); +}); + +describe('isValidationError', () => { + it('returns true for 400 errors', () => { + const error = { + response: { + status: 400, + }, + }; + expect(isValidationError(error)).toBe(true); + }); + + it('returns true for 422 errors', () => { + const error = { + response: { + status: 422, + }, + }; + expect(isValidationError(error)).toBe(true); + }); + + it('returns false for other status codes', () => { + const error = { + response: { + status: 401, + }, + }; + expect(isValidationError(error)).toBe(false); + }); + + it('returns false for network errors', () => { + const error = { + request: {}, + }; + expect(isValidationError(error)).toBe(false); + }); +}); diff --git a/payego_ui/src/utils/errorHandler.ts b/payego_ui/src/utils/errorHandler.ts index cc9921d..45c1c1f 100644 --- a/payego_ui/src/utils/errorHandler.ts +++ b/payego_ui/src/utils/errorHandler.ts @@ -14,6 +14,11 @@ export interface ApiErrorResponse { * @returns A user-friendly error message */ export function getErrorMessage(error: any): string { + // Handle null/undefined errors + if (!error) { + return 'An unexpected error occurred. Please try again.'; + } + // Handle Axios errors with response if (error.response) { const data: ApiErrorResponse = error.response.data; @@ -81,7 +86,7 @@ export function getErrorMessage(error: any): string { * Checks if an error is a network error */ export function isNetworkError(error: any): boolean { - return error.request && !error.response; + return !!error && !!error.request && !error.response; } /** diff --git a/payego_ui/src/utils/test-utils.tsx b/payego_ui/src/utils/test-utils.tsx new file mode 100644 index 0000000..79b9eec --- /dev/null +++ b/payego_ui/src/utils/test-utils.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '../contexts/AuthContext'; +import { BrowserRouter } from 'react-router-dom'; +import type { ReactElement } from 'react'; + +/** + * Custom render function that wraps components with necessary providers + * Use this instead of @testing-library/react's render for component tests + */ +export function renderWithProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); + + return render( + + + + {ui} + + + + ); +} + +// Re-export everything from @testing-library/react +export * from '@testing-library/react'; + +// Override render with our custom version +export { renderWithProviders as render };