diff --git a/config.yaml b/config.yaml index ce7b5c905..c2a4364cb 100644 --- a/config.yaml +++ b/config.yaml @@ -29,12 +29,12 @@ admin_password: "hunter2" secret_key: insecure-change-in-production # Payment configuration +# Dummy provider is enabled by default for testing +# For production, configure Stripe or another provider payment: - stripe: - api_key: "TEST API KEY you'll need to replace this with the one from stripe" - webhook_secret: "whsec_8c7f1687b9e1ca58dfe81a28a5de3fd1fdffac83821f8f4cabc6ad2145669cde" - price_id: "price_1SUSd1GdjfBnc3h7uHVkmhGg" - host_url: "http://localhost:3001" # Base URL for webhooks and redirects + dummy: + host_url: "http://localhost:3001" + amount: 100.00 # Model sources - inference endpoints to connect to # Uncomment and configure as needed diff --git a/dashboard/src/api/control-layer/client.ts b/dashboard/src/api/control-layer/client.ts index 23859c86d..d4bfcca52 100644 --- a/dashboard/src/api/control-layer/client.ts +++ b/dashboard/src/api/control-layer/client.ts @@ -810,12 +810,7 @@ const paymentsApi = { response, ); } - const errorData = await response.json().catch(() => ({})); - throw new ApiError( - response.status, - errorData.message || "Failed to process payment", - response, - ); + throw new Error(`Failed to process transaction: ${response.status}`); } // Explicitly return to ensure promise resolves diff --git a/dashboard/src/api/control-layer/hooks.ts b/dashboard/src/api/control-layer/hooks.ts index 31dc58f3b..3c1171b41 100644 --- a/dashboard/src/api/control-layer/hooks.ts +++ b/dashboard/src/api/control-layer/hooks.ts @@ -943,7 +943,6 @@ export function useProcessPayment(options?: { options?.onSuccess?.(); }, onError: (error) => { - console.error('[useProcessPayment] onError callback triggered:', error); // Call the component's error callback if provided options?.onError?.(error as Error); }, diff --git a/dashboard/src/api/control-layer/types.ts b/dashboard/src/api/control-layer/types.ts index 5345e50e1..51858c913 100644 --- a/dashboard/src/api/control-layer/types.ts +++ b/dashboard/src/api/control-layer/types.ts @@ -22,7 +22,6 @@ export type ApiKeyPurpose = "platform" | "inference"; export interface ConfigResponse { region: string; organization: string; - registration_enabled: boolean; payment_enabled: boolean; } diff --git a/dashboard/src/components/features/cost-management/CostManagement/CostManagement.tsx b/dashboard/src/components/features/cost-management/CostManagement/CostManagement.tsx index 543842796..1ad4b2ef2 100644 --- a/dashboard/src/components/features/cost-management/CostManagement/CostManagement.tsx +++ b/dashboard/src/components/features/cost-management/CostManagement/CostManagement.tsx @@ -28,13 +28,9 @@ export function CostManagement() { const processPaymentMutation = useProcessPayment({ onSuccess: () => { setTimeout(() => { - console.log('Closing modal now'); setShowSuccessModal(false); }, 2000); }, - onError: (error) => { - console.error('Payment processing error:', error); - } }); // Handle return from payment provider @@ -87,9 +83,8 @@ export function CostManagement() { description: "Funds purchase - Demo top up" }); toast.success(`Added $${fundAmount.toFixed(2)}`); - } catch (error) { + } catch { toast.error("Failed to add funds"); - console.error("Error adding funds:", error); } } else if (config?.payment_enabled) { // Payment processing enabled: Get checkout URL and redirect using the mutation hook @@ -101,10 +96,8 @@ export function CostManagement() { } else { toast.error("Failed to get checkout URL"); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to initiate payment"; - toast.error(errorMessage); - console.error("Error creating payment:", error); + } catch { + toast.error("Failed to transfer to payment provider."); } } else { toast.error("Payment processing is not configured"); @@ -161,14 +154,9 @@ export function CostManagement() { "Processing your payment and updating your account balance..." ) : processPaymentMutation.isError ? (
-

- {processPaymentMutation.error instanceof Error - ? processPaymentMutation.error.message - : "Failed to process payment"} -

+

Your payment has been captured but not yet applied to your account.

- Your payment may have been successful, but we couldn't confirm it yet. - If your balance doesn't update within a few minutes, please contact support. + Your balance should update automatically within a few minutes. If it doesn't, please contact support.

) : ( diff --git a/dwctl/src/api/handlers/config.rs b/dwctl/src/api/handlers/config.rs index 4ba6a4c47..cb401f807 100644 --- a/dwctl/src/api/handlers/config.rs +++ b/dwctl/src/api/handlers/config.rs @@ -10,7 +10,6 @@ use crate::{AppState, api::models::users::CurrentUser}; pub struct ConfigResponse { pub region: String, pub organization: String, - pub registration_enabled: bool, pub payment_enabled: bool, } @@ -36,8 +35,6 @@ pub async fn get_config(State(state): State, _user: CurrentUser) -> im let response = ConfigResponse { region: metadata.region.clone(), organization: metadata.organization.clone(), - // Compute registration_enabled based on native auth configuration - registration_enabled: state.config.auth.native.enabled && state.config.auth.native.allow_registration, // Compute payment_enabled based on whether payment_processor is configured payment_enabled: state.config.payment.is_some(), }; @@ -71,7 +68,6 @@ mod tests { // Check that metadata fields are present assert!(json.get("region").is_some()); assert!(json.get("organization").is_some()); - assert!(json.get("registration_enabled").is_some()); } #[sqlx::test] diff --git a/dwctl/src/api/handlers/payments.rs b/dwctl/src/api/handlers/payments.rs index f6c58cf7f..6c8023a14 100644 --- a/dwctl/src/api/handlers/payments.rs +++ b/dwctl/src/api/handlers/payments.rs @@ -1,9 +1,73 @@ //! HTTP handlers for payment processing endpoints. +//! +//! # Payment Flow +//! +//! The payment system supports multiple payment providers (Stripe, PayPal, etc.) through +//! a unified abstraction layer. The flow works as follows: +//! +//! ## 1. Checkout Session Creation +//! +//! **Endpoint**: `POST /admin/api/v1/payments` +//! +//! - User initiates payment from the frontend +//! - Backend creates a checkout session with the configured payment provider +//! - Returns a checkout URL for the frontend to redirect the user to +//! - Requires configured `host_url` in payment config for building redirect URLs +//! +//! ## 2. User Completes Payment +//! +//! - User is redirected to payment provider (e.g., Stripe Checkout) +//! - User completes payment on provider's secure page +//! - Provider redirects user back to success or cancel URL +//! +//! ## 3. Payment Confirmation +//! +//! ### Path A: Webhook (Primary, Automatic) +//! +//! **Endpoint**: `POST /admin/api/v1/webhooks/payments` +//! +//! - Payment provider sends webhook event when payment completes +//! - Backend validates webhook signature +//! - Processes payment and credits user account +//! - Returns 200 OK (even on processing errors to prevent retries) +//! +//! ### Path B: Manual Processing (Fallback) +//! +//! **Endpoint**: `PATCH /admin/api/v1/payments/{session_id}` +//! +//! - Frontend can trigger payment processing manually using session ID +//! - Useful when webhooks fail or for immediate confirmation +//! - Idempotent - safe to call multiple times +//! - Returns 402 if payment not yet completed by provider +//! +//! ## Idempotency +//! +//! Payment processing is idempotent - processing the same session multiple times +//! (via webhooks or manual triggers) will not create duplicate transactions. +//! +//! ## Frontend Integration +//! +//! The frontend payment flow: +//! +//! 1. **Initiate Payment**: Call `POST /admin/api/v1/payments` to get checkout URL +//! 2. **Redirect**: Navigate user to the returned checkout URL (payment provider page) +//! 3. **Handle Return**: Payment provider redirects back with query parameters: +//! - Success: `?payment=success&session_id={SESSION_ID}` +//! - Cancelled: `?payment=cancelled&session_id={SESSION_ID}` +//! 4. **Process Payment**: On success, call `PATCH /admin/api/v1/payments/{session_id}` +//! to confirm and apply payment to account +//! 5. **Show Feedback**: Display appropriate UI based on result: +//! - Success: "Payment processed successfully" +//! - Error: "Payment captured but not yet applied. Will update automatically." +//! 6. **Clean URL**: Remove query parameters from URL after processing +//! +//! The frontend should handle errors gracefully - if manual processing fails, +//! the webhook will eventually process the payment automatically. use axum::{ Json, extract::State, - http::{StatusCode, header}, + http::StatusCode, response::{IntoResponse, Response}, }; use serde_json::json; @@ -27,60 +91,34 @@ use crate::{AppState, api::models::users::CurrentUser, payment_providers}; ) )] #[tracing::instrument(skip_all)] -pub async fn create_payment( - State(state): State, - headers: axum::http::HeaderMap, - user: CurrentUser, -) -> Result { +pub async fn create_payment(State(state): State, user: CurrentUser) -> Result { // Get payment provider from config (generic - works for any provider) let payment_config = match state.config.payment.clone() { Some(config) => config, None => { tracing::warn!("Checkout requested but no payment provider is configured"); let error_response = Json(json!({ - "error": "No payment provider configured", - "message": "Sorry, there's no payment provider setup. Please contact support." + "message": "Payment processing is currently unavailable. Please contact support." })); - return Ok((StatusCode::NOT_IMPLEMENTED, error_response).into_response()); + return Ok((StatusCode::SERVICE_UNAVAILABLE, error_response).into_response()); } }; - // Build redirect URLs from configured host URL (preferred) or fallback to request headers - let origin = if let Some(configured_host) = payment_config.host_url() { - // Use configured host URL - this is the reliable, recommended approach - tracing::info!("Using configured host URL for checkout redirect: {}", configured_host); - configured_host.to_string() - } else { - // Fallback to reading from request headers (less reliable) - tracing::warn!("No host_url configured in payment config, falling back to request headers (unreliable)"); - headers - .get(header::ORIGIN) - .or_else(|| headers.get(header::REFERER)) - .and_then(|h| h.to_str().ok()) - .and_then(|s| { - // If it's a referer, extract just the origin part - if let Ok(url) = url::Url::parse(s) { - url.origin().ascii_serialization().into() - } else { - Some(s.to_string()) - } - }) - .unwrap_or_else(|| { - // Fallback to constructing from Host header - let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("localhost:3001"); - - // Determine protocol - check X-Forwarded-Proto for proxied requests - let proto = headers.get("x-forwarded-proto").and_then(|h| h.to_str().ok()).unwrap_or("http"); - - format!("{}://{}", proto, host) - }) + // Build redirect URLs from configured host URL + let origin = match payment_config.host_url() { + Some(configured_host) => configured_host.to_string(), + None => { + tracing::error!("No host_url configured in payment config - this is required for payment processing"); + let error_response = Json(json!({ + "message": "Payment processing is currently unavailable. Please contact support." + })); + return Ok((StatusCode::SERVICE_UNAVAILABLE, error_response).into_response()); + } }; let success_url = format!("{}/cost-management?payment=success&session_id={{CHECKOUT_SESSION_ID}}", origin); let cancel_url = format!("{}/cost-management?payment=cancelled&session_id={{CHECKOUT_SESSION_ID}}", origin); - tracing::info!("Building checkout URLs with origin: {}", origin); - let provider = payment_providers::create_provider(payment_config); // Create checkout session using the provider trait @@ -132,10 +170,9 @@ pub async fn process_payment( None => { tracing::warn!("Payment processing requested but no payment provider is configured"); return Ok(( - StatusCode::NOT_IMPLEMENTED, + StatusCode::SERVICE_UNAVAILABLE, Json(json!({ - "error": "No payment provider configured", - "message": "Payment provider is not configured" + "message": "Payment processing is currently unavailable. Please contact support." })), ) .into_response()); @@ -145,23 +182,27 @@ pub async fn process_payment( // Process the payment session using the provider trait match provider.process_payment_session(&state.db, &id).await { Ok(()) => Ok(Json(json!({ - "success": true, "message": "Payment processed successfully" })) .into_response()), Err(e) => { - let status = StatusCode::from(e); - if status == StatusCode::PAYMENT_REQUIRED { + tracing::error!("Failed to process payment session: {:?}", e); + if matches!(e, payment_providers::PaymentError::PaymentNotCompleted) { Ok(( StatusCode::PAYMENT_REQUIRED, Json(json!({ - "error": "Payment not completed", - "message": "The payment has not been completed yet" + "message": "Payment is still processing. Please check back in a moment." })), ) .into_response()) } else { - Err(status) + Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "message": "Unable to process payment. Please contact support." + })), + ) + .into_response()) } } } @@ -207,12 +248,12 @@ pub async fn webhook_handler(State(state): State, headers: axum::http: } }; - tracing::info!("Received webhook event: {}", event.event_type); + tracing::trace!("Received webhook event: {}", event.event_type); // Process the webhook event match provider.process_webhook_event(&state.db, &event).await { Ok(()) => { - tracing::info!("Successfully processed webhook event: {}", event.event_type); + tracing::trace!("Successfully processed webhook event: {}", event.event_type); StatusCode::OK } Err(e) => { @@ -222,3 +263,192 @@ pub async fn webhook_handler(State(state): State, headers: axum::http: } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::DummyConfig; + use crate::{config::PaymentConfig, test_utils::create_test_config}; + use axum::Router; + use axum::routing::{patch, post}; + use axum_test::TestServer; + use rust_decimal::Decimal; + use sqlx::PgPool; + + #[sqlx::test] + async fn test_dummy_payment_flow(pool: PgPool) { + // Setup config with dummy payment provider + let mut config = create_test_config(); + config.payment = Some(PaymentConfig::Dummy(DummyConfig { + host_url: Some("http://localhost:3001".to_string()), + amount: Some(Decimal::new(100, 0)), // $100 + })); + + let request_manager = std::sync::Arc::new(fusillade::PostgresRequestManager::new(pool.clone())); + let state = AppState::builder() + .db(pool.clone()) + .config(config) + .request_manager(request_manager) + .build(); + + // Create a test user + let user = crate::test_utils::create_test_user(&pool, crate::api::models::users::Role::StandardUser).await; + let auth_headers = crate::test_utils::add_auth_headers(&user); + + let app = Router::new() + .route("/payments", post(create_payment)) + .route("/payments/{id}", patch(process_payment)) + .with_state(state); + + let server = TestServer::new(app).unwrap(); + + // Step 1: Create checkout session + let mut request = server.post("/payments"); + for (key, value) in &auth_headers { + request = request.add_header(key.as_str(), value.as_str()); + } + let response = request.await; + + response.assert_status(StatusCode::OK); + let checkout_response: serde_json::Value = response.json(); + let checkout_url = checkout_response["url"].as_str().unwrap(); + + // Verify URL contains session_id + assert!(checkout_url.contains("session_id=")); + assert!(checkout_url.contains("payment=success")); + + // Extract session_id from URL + let url = url::Url::parse(checkout_url).unwrap(); + let query_pairs: std::collections::HashMap<_, _> = url.query_pairs().collect(); + let session_id = query_pairs.get("session_id").unwrap(); + + // Step 2: Verify NO transaction was created yet (matches real payment flow) + let count_before = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count_before.count.unwrap(), 0, "Transaction should not exist before processing"); + + // Step 3: Process payment to create transaction + let mut request = server.patch(&format!("/payments/{}", session_id)); + for (key, value) in &auth_headers { + request = request.add_header(key.as_str(), value.as_str()); + } + let response = request.await; + + response.assert_status(StatusCode::OK); + let process_response: serde_json::Value = response.json(); + assert_eq!(process_response["message"], "Payment processed successfully"); + + // Step 4: Verify transaction was created + let transaction = sqlx::query!( + r#" + SELECT amount, user_id, source_id + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(transaction.amount, Decimal::new(100, 0)); + assert_eq!(transaction.user_id, user.id); + + // Step 5: Process again to verify idempotency + let mut request = server.patch(&format!("/payments/{}", session_id)); + for (key, value) in &auth_headers { + request = request.add_header(key.as_str(), value.as_str()); + } + let response = request.await; + + response.assert_status(StatusCode::OK); + + // Step 6: Verify no duplicate transactions + let count = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count.count.unwrap(), 1, "Should only have one transaction (idempotent)"); + } + + #[sqlx::test] + async fn test_payment_no_provider_configured(pool: PgPool) { + // Setup config WITHOUT payment provider + let config = create_test_config(); + + let request_manager = std::sync::Arc::new(fusillade::PostgresRequestManager::new(pool.clone())); + let state = AppState::builder() + .db(pool.clone()) + .config(config) + .request_manager(request_manager) + .build(); + + let user = crate::test_utils::create_test_user(&pool, crate::api::models::users::Role::StandardUser).await; + let auth_headers = crate::test_utils::add_auth_headers(&user); + + let app = Router::new().route("/payments", post(create_payment)).with_state(state); + + let server = TestServer::new(app).unwrap(); + + let mut request = server.post("/payments"); + for (key, value) in &auth_headers { + request = request.add_header(key.as_str(), value.as_str()); + } + let response = request.await; + + response.assert_status(StatusCode::SERVICE_UNAVAILABLE); + let error_response: serde_json::Value = response.json(); + assert!(error_response["message"].as_str().unwrap().contains("unavailable")); + } + + #[sqlx::test] + async fn test_payment_no_host_url(pool: PgPool) { + // Setup config with dummy provider but NO host_url + let mut config = create_test_config(); + config.payment = Some(PaymentConfig::Dummy(DummyConfig { + host_url: None, + amount: Some(Decimal::new(50, 0)), + })); + + let request_manager = std::sync::Arc::new(fusillade::PostgresRequestManager::new(pool.clone())); + let state = AppState::builder() + .db(pool.clone()) + .config(config) + .request_manager(request_manager) + .build(); + + let user = crate::test_utils::create_test_user(&pool, crate::api::models::users::Role::StandardUser).await; + let auth_headers = crate::test_utils::add_auth_headers(&user); + + let app = Router::new().route("/payments", post(create_payment)).with_state(state); + + let server = TestServer::new(app).unwrap(); + + let mut request = server.post("/payments"); + for (key, value) in &auth_headers { + request = request.add_header(key.as_str(), value.as_str()); + } + let response = request.await; + + response.assert_status(StatusCode::SERVICE_UNAVAILABLE); + let error_response: serde_json::Value = response.json(); + assert!(error_response["message"].as_str().unwrap().contains("unavailable")); + } +} diff --git a/dwctl/src/config.rs b/dwctl/src/config.rs index c2d14b3e4..c61f1cffc 100644 --- a/dwctl/src/config.rs +++ b/dwctl/src/config.rs @@ -365,8 +365,6 @@ pub struct Metadata { pub region: String, /// Organization name displayed in the UI pub organization: String, - /// Whether user registration is enabled (shown in frontend) - pub registration_enabled: bool, } /// External model source configuration. @@ -876,7 +874,6 @@ impl Default for Metadata { Self { region: "UK South".to_string(), organization: "ACME Corp".to_string(), - registration_enabled: true, } } } diff --git a/dwctl/src/db/handlers/api_keys.rs b/dwctl/src/db/handlers/api_keys.rs index 8c4678e1c..6594745f3 100644 --- a/dwctl/src/db/handlers/api_keys.rs +++ b/dwctl/src/db/handlers/api_keys.rs @@ -1709,7 +1709,6 @@ mod tests { metadata: crate::config::Metadata { region: "Test Region".to_string(), organization: "Test Org".to_string(), - registration_enabled: false, }, auth: Default::default(), enable_metrics: false, diff --git a/dwctl/src/payment_providers/PAYMENT_PROVIDERS.md b/dwctl/src/payment_providers/PAYMENT_PROVIDERS.md deleted file mode 100644 index c6be27048..000000000 --- a/dwctl/src/payment_providers/PAYMENT_PROVIDERS.md +++ /dev/null @@ -1,798 +0,0 @@ -# Payment Provider Integration Guide - -This document explains how the payment provider system works in dwctl, including configuration, architecture, API endpoints, and how to implement new payment providers. - -## Table of Contents - -- [Overview](#overview) -- [Architecture](#architecture) -- [Configuration](#configuration) -- [Payment Flow](#payment-flow) -- [API Endpoints](#api-endpoints) -- [Implementing a New Provider](#implementing-a-new-provider) -- [Frontend Integration](#frontend-integration) - -## Overview - -The dwctl payment system provides a flexible abstraction layer for integrating various payment providers (Stripe, PayPal, etc.) to enable users to purchase credits. The system uses a redirect-based checkout flow where users are sent to the payment provider's hosted checkout page, complete payment, and are redirected back to the application. - -### Key Features - -- **Provider abstraction**: Single trait-based interface for all payment providers -- **Webhook support**: Automatic balance updates via provider webhooks -- **Idempotency**: Prevents duplicate credit transactions -- **Hosted checkout**: Users complete payment on provider's secure page -- **Flexible configuration**: Environment variable-based provider setup - -## Architecture - -### Component Overview - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Frontend (React) │ -│ - Cost Management page │ -│ - Triggers payment flow │ -│ - Handles success/cancel redirects │ -└──────────────────────┬──────────────────────────────────────────┘ - │ - │ POST /admin/api/v1/payments - │ -┌──────────────────────▼──────────────────────────────────────────┐ -│ Payment Handler (Rust) │ -│ - Creates checkout session │ -│ - Returns checkout URL │ -└──────────────────────┬──────────────────────────────────────────┘ - │ - │ Uses - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ PaymentProvider Trait (Abstraction) │ -│ - create_checkout_session() │ -│ - process_payment_session() │ -│ - validate_webhook() │ -│ - process_webhook_event() │ -└──────────┬──────────────────────────────────────────────────────┘ - │ - ├─── StripeProvider (impl PaymentProvider) - ├─── DummyProvider (impl PaymentProvider) - └─── [Your Provider] (impl PaymentProvider) - │ - │ API calls - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ External Payment Provider │ -│ - Stripe / PayPal / etc. │ -│ - Hosted checkout page │ -│ - Webhook delivery │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### File Structure - -``` -dwctl/ -├── src/ -│ ├── config.rs # Payment provider configuration -│ ├── api/handlers/payments.rs # HTTP handlers for payment endpoints -│ ├── payment_providers/ -│ │ ├── mod.rs # PaymentProvider trait definition -│ │ ├── stripe.rs # Stripe implementation -│ │ └── dummy.rs # Dummy/test implementation -│ └── db/models/credits.rs # Credit transaction models -└── docs/ - └── PAYMENT_PROVIDERS.md # This document -``` - -## Configuration - -### Backend Configuration - -Payment providers are configured via `config.yaml` or environment variables. The configuration is defined in `src/config.rs`. - -#### Stripe Configuration - -**In `config.yaml`:** - -```yaml -payment: - stripe: - api_key: "sk_test_..." - webhook_secret: "whsec_..." - price_id: "price_..." - host_url: "https://app.example.com" # Where users are redirected after payment -``` - -**Via Environment Variables:** - -```bash -DWCTL_PAYMENT__STRIPE__API_KEY="sk_test_..." -DWCTL_PAYMENT__STRIPE__WEBHOOK_SECRET="whsec_..." -DWCTL_PAYMENT__STRIPE__PRICE_ID="price_..." -DWCTL_PAYMENT__STRIPE__HOST_URL="https://app.example.com" -``` - -#### Dummy Provider (for testing) - -```yaml -payment: - dummy: - amount: 50.0 # Default amount in dollars - host_url: "http://localhost:3001" -``` - -### Configuration Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `api_key` | Yes (Stripe) | Payment provider API secret key | -| `webhook_secret` | Yes (Stripe) | Webhook signature verification secret | -| `price_id` | Yes (Stripe) | Product/price ID from payment provider | -| `host_url` | Yes | Base URL for redirect URLs (e.g., `https://app.example.com`) | -| `amount` | No (Dummy) | Default credit amount for dummy provider | - -### Why `host_url`? - -Previously, the system attempted to read the redirect URL from request headers (`Origin`, `Referer`, `Host`, `X-Forwarded-Proto`). This was unreliable because: - -- Headers can be missing or incorrect -- Proxy setups can complicate header values -- Security-conscious browsers may omit certain headers -- Header spoofing attacks - -The `host_url` configuration provides a reliable, explicit setting for where users should be redirected after payment. - -## Payment Flow - -### Complete User Journey - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 1. User clicks "Add Funds" on Cost Management page │ -└───────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────┐ -│ 2. Frontend: POST /admin/api/v1/payments │ -│ - No request body needed (user from auth) │ -└───────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────┐ -│ 3. Backend: create_payment() handler │ -│ - Gets payment config (stripe/dummy) │ -│ - Determines redirect URLs from config.host_url │ -│ - Calls provider.create_checkout_session() │ -│ - Returns JSON: { "url": "https://checkout.stripe.com/..." } │ -└───────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────┐ -│ 4. Frontend: Redirects browser to checkout URL │ -│ window.location.href = response.url │ -└───────────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────┐ -│ 5. User completes payment on provider's hosted page │ -│ (Stripe/PayPal/etc.) │ -└───────────────────────────┬──────────────────────────────────────┘ - │ - ├─────────────────────┐ - │ │ - ▼ ▼ -┌───────────────────────────────────┐ ┌────────────────────────────┐ -│ 6a. Webhook (Async) │ │ 6b. User Redirect (Sync) │ -│ POST /admin/api/v1/webhooks/... │ │ Browser → success_url │ -│ - Provider sends event │ │ with ?session_id=... │ -│ - validate_webhook() │ │ │ -│ - process_webhook_event() │ │ │ -│ - Credits added to account │ │ │ -└───────────────────────────────────┘ └────────┬───────────────────┘ - │ - ▼ - ┌────────────────────────────────┐ - │ 7. Frontend: Payment Success │ - │ - Detects ?payment=success │ - │ - Calls PATCH /payments/:id │ - │ - Shows success modal │ - │ - Refreshes balance │ - └────────────────────────────────┘ -``` - -### Redirect URLs - -The system constructs two redirect URLs: - -1. **Success URL**: `{host_url}/cost-management?payment=success&session_id={CHECKOUT_SESSION_ID}` -2. **Cancel URL**: `{host_url}/cost-management?payment=cancelled&session_id={CHECKOUT_SESSION_ID}` - -The `{CHECKOUT_SESSION_ID}` placeholder is replaced by the payment provider with the actual session ID. - -### Idempotency - -The system ensures idempotent credit transactions through: - -1. **Fast path check**: Before making expensive API calls to the provider, check if a transaction with the given `source_id` already exists -2. **Unique constraint**: Database has a unique constraint on `credits_transactions.source_id` -3. **Race condition handling**: If two replicas process the same payment simultaneously, the second one catches the unique constraint violation and returns success - -This prevents: -- Duplicate credits from webhook retries -- Double-processing from user refreshing the success page -- Race conditions in multi-instance deployments - -## API Endpoints - -### 1. Create Payment - -Creates a payment checkout session and returns the checkout URL. - -**Endpoint**: `POST /admin/api/v1/payments` - -**Authentication**: Required (Bearer token, session cookie, or proxy headers) - -**Request**: No body required (user extracted from authentication) - -**Response**: -```json -{ - "url": "https://checkout.stripe.com/c/pay/cs_test_..." -} -``` - -**Implementation** (`src/api/handlers/payments.rs`): - -```rust -pub async fn create_payment( - State(state): State, - headers: axum::http::HeaderMap, - user: CurrentUser, -) -> Result -``` - -**Flow**: -1. Get payment config from `state.config.payment` -2. Determine `origin` from `config.host_url()` (or fallback to headers if not configured) -3. Build success/cancel URLs: `{origin}/cost-management?payment=...&session_id={CHECKOUT_SESSION_ID}` -4. Call `provider.create_checkout_session(&db, &user, &cancel_url, &success_url)` -5. Return checkout URL as JSON - -### 2. Process Payment - -Manually processes a payment session (useful as webhook fallback). - -**Endpoint**: `PATCH /admin/api/v1/payments/:id` - -**Authentication**: Required - -**Parameters**: `:id` - Payment session ID from provider - -**Response**: -```json -{ - "success": true, - "message": "Payment processed successfully" -} -``` - -**Implementation**: - -```rust -pub async fn process_payment( - State(state): State, - axum::extract::Path(id): axum::extract::Path, - _user: CurrentUser, -) -> Result -``` - -**Flow**: -1. Get payment provider from config -2. Call `provider.process_payment_session(&db, &session_id)` -3. Provider fetches session details, verifies payment, creates credit transaction -4. Returns success or appropriate error status - -### 3. Webhook Handler - -Receives and processes webhook events from payment providers. - -**Endpoint**: `POST /admin/api/v1/webhooks/payments` - -**Authentication**: None (validated via webhook signature) - -**Request**: Raw body from payment provider - -**Headers**: Provider-specific signature header (e.g., `stripe-signature`) - -**Response**: `200 OK` or `400 Bad Request` - -**Implementation**: - -```rust -pub async fn webhook_handler( - State(state): State, - headers: axum::http::HeaderMap, - body: String -) -> StatusCode -``` - -**Flow**: -1. Get payment provider from config -2. Call `provider.validate_webhook(&headers, &body)` - - Provider verifies webhook signature - - Parses event data - - Returns `WebhookEvent` struct -3. Call `provider.process_webhook_event(&db, &event)` - - Only processes `checkout.session.completed` type events - - Extracts session ID - - Calls `process_payment_session()` to credit user -4. Always returns `200 OK` (even on errors) to prevent webhook retries for already-processed events - -## Implementing a New Provider - -To add a new payment provider (e.g., PayPal, Square), follow these steps: - -### Step 1: Add Configuration - -**In `src/config.rs`**, add your provider to the `PaymentConfig` enum: - -```rust -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum PaymentConfig { - Stripe(StripeConfig), - Dummy(DummyConfig), - // Add your provider - Paypal(PaypalConfig), -} - -// Define your config struct -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct PaypalConfig { - pub client_id: String, - pub client_secret: String, - pub host_url: String, -} - -``` - -### Step 2: Create Provider Implementation - -**Create `src/payment_providers/paypal.rs`:** - -```rust -use async_trait::async_trait; -use rust_decimal::Decimal; -use sqlx::PgPool; - -use crate::{ - api::models::users::CurrentUser, - db::{ - handlers::credits::Credits, - models::credits::{CreditTransactionCreateDBRequest, CreditTransactionType}, - }, - payment_providers::{PaymentError, PaymentProvider, PaymentSession, Result, WebhookEvent}, - types::UserId, -}; - -pub struct PaypalProvider { - client_id: String, - client_secret: String, -} - -impl PaypalProvider { - pub fn new(client_id: String, client_secret: String) -> Self { - Self { - client_id, - client_secret, - } - } - - fn client(&self) -> PaypalClient { - // Initialize PayPal SDK client - PaypalClient::new(&self.client_id, &self.client_secret) - } -} - -#[async_trait] -impl PaymentProvider for PaypalProvider { - async fn create_checkout_session( - &self, - db_pool: &PgPool, - user: &CurrentUser, - cancel_url: &str, - success_url: &str, - ) -> Result { - let client = self.client(); - - // Create PayPal order - let order = client.create_order( - amount: "10.00", - currency: "USD", - return_url: success_url, - cancel_url: cancel_url, - // ... other PayPal parameters - ).await.map_err(|e| { - tracing::error!("Failed to create PayPal order: {:?}", e); - PaymentError::ProviderApi(e.to_string()) - })?; - - // Extract approval URL for user redirect - let approval_url = order.links - .iter() - .find(|link| link.rel == "approve") - .map(|link| link.href.clone()) - .ok_or_else(|| PaymentError::ProviderApi("No approval URL".to_string()))?; - - Ok(approval_url) - } - - async fn get_payment_session(&self, session_id: &str) -> Result { - let client = self.client(); - - // Retrieve PayPal order details - let order = client.get_order(session_id).await.map_err(|e| { - tracing::error!("Failed to retrieve PayPal order: {:?}", e); - PaymentError::ProviderApi(e.to_string()) - })?; - - // Extract relevant information - Ok(PaymentSession { - id: order.id.clone(), - user_id: order.custom_id.ok_or_else(|| { - PaymentError::InvalidData("Missing custom_id".to_string()) - })?, - amount: Decimal::from_str(&order.amount.value) - .map_err(|e| PaymentError::InvalidData(e.to_string()))?, - is_paid: order.status == "COMPLETED", - customer_id: order.payer.payer_id.clone(), - }) - } - - async fn process_payment_session(&self, db_pool: &PgPool, session_id: &str) -> Result<()> { - // Fast path: Check if already processed - let existing = sqlx::query!( - "SELECT id FROM credits_transactions WHERE source_id = $1", - session_id - ) - .fetch_optional(db_pool) - .await?; - - if existing.is_some() { - tracing::info!("Transaction {} already processed", session_id); - return Ok(()); - } - - // Get payment session and verify it's paid - let payment_session = self.get_payment_session(session_id).await?; - if !payment_session.is_paid { - return Err(PaymentError::PaymentNotCompleted); - } - - // Create credit transaction - let mut conn = db_pool.acquire().await?; - let mut credits = Credits::new(&mut conn); - - let user_id: UserId = payment_session.user_id.parse() - .map_err(|e| PaymentError::InvalidData(format!("Invalid user ID: {}", e)))?; - - let request = CreditTransactionCreateDBRequest { - user_id, - transaction_type: CreditTransactionType::Purchase, - amount: payment_session.amount, - source_id: session_id.to_string(), - description: Some("PayPal payment".to_string()), - }; - - match credits.create_transaction(&request).await { - Ok(_) => Ok(()), - Err(crate::db::errors::DbError::UniqueViolation { constraint, .. }) - if constraint.as_deref() == Some("credits_transactions_source_id_unique") => - { - tracing::info!("Transaction {} already processed (unique constraint)", session_id); - Ok(()) - } - Err(e) => { - tracing::error!("Failed to create transaction: {:?}", e); - Err(PaymentError::Database(sqlx::Error::RowNotFound)) - } - } - } - - async fn validate_webhook( - &self, - headers: &axum::http::HeaderMap, - body: &str, - ) -> Result> { - // Get PayPal webhook signature headers - let signature = headers - .get("paypal-transmission-sig") - .ok_or_else(|| PaymentError::InvalidData("Missing signature header".to_string()))? - .to_str() - .map_err(|_| PaymentError::InvalidData("Invalid signature header".to_string()))?; - - // Verify webhook signature using PayPal SDK - let client = self.client(); - let verified = client.verify_webhook(body, signature, &self.webhook_id).await - .map_err(|e| PaymentError::InvalidData(format!("Webhook verification failed: {}", e)))?; - - if !verified { - return Err(PaymentError::InvalidData("Invalid webhook signature".to_string())); - } - - // Parse webhook event - let event: PaypalWebhookEvent = serde_json::from_str(body) - .map_err(|e| PaymentError::InvalidData(format!("Failed to parse webhook: {}", e)))?; - - Ok(Some(WebhookEvent { - event_type: event.event_type, - session_id: Some(event.resource.id), - raw_data: serde_json::to_value(&event).unwrap_or(serde_json::Value::Null), - })) - } - - async fn process_webhook_event(&self, db_pool: &PgPool, event: &WebhookEvent) -> Result<()> { - // Only process order completion events - if event.event_type != "CHECKOUT.ORDER.COMPLETED" { - tracing::debug!("Ignoring webhook event: {}", event.event_type); - return Ok(()); - } - - let session_id = event.session_id.as_ref() - .ok_or_else(|| PaymentError::InvalidData("Missing session_id".to_string()))?; - - tracing::info!("Processing PayPal webhook for order: {}", session_id); - self.process_payment_session(db_pool, session_id).await - } -} -``` - -### Step 3: Register Provider - -**In `src/payment_providers/mod.rs`:** - -```rust -pub mod dummy; -pub mod stripe; -pub mod paypal; // Add this - -pub fn create_provider(config: PaymentConfig) -> Box { - match config { - PaymentConfig::Stripe(stripe_config) => Box::new(stripe::StripeProvider::new( - stripe_config.api_key, - stripe_config.price_id, - stripe_config.webhook_secret, - )), - PaymentConfig::Dummy(dummy_config) => { - let amount = dummy_config.amount.unwrap_or(Decimal::new(50, 0)); - Box::new(dummy::DummyProvider::new(amount)) - }, - // Add your provider - PaymentConfig::Paypal(paypal_config) => Box::new(paypal::PaypalProvider::new( - paypal_config.client_id, - paypal_config.client_secret, - )), - } -} -``` - -### Step 4: Configure Provider - -**In `config.yaml`:** - -```yaml -payment: - paypal: - client_id: "your-client-id" - client_secret: "your-client-secret" - host_url: "https://app.example.com" -``` - -### Key Implementation Notes - -1. **create_checkout_session**: Must return a URL where users can complete payment -2. **get_payment_session**: Must return session details including payment status -3. **process_payment_session**: Must be idempotent (check for existing transaction first) -4. **validate_webhook**: Must verify webhook signature to prevent spoofing -5. **process_webhook_event**: Should only process completion events -6. **Error handling**: Use `PaymentError` enum variants appropriately -7. **Logging**: Add tracing for debugging and monitoring - -## Frontend Integration - -### Cost Management Component - -The frontend initiates payments through the Cost Management page (`src/components/features/cost-management/CostManagement/CostManagement.tsx`). - -**Payment Flow**: - -```typescript -const handleAddFunds = async () => { - if (config?.payment_enabled) { - try { - // 1. Call backend to create checkout session - const data = await createPaymentMutation.mutateAsync(); - - // 2. Redirect to payment provider - if (data.url) { - window.location.href = data.url; - } - } catch (error) { - toast.error("Failed to initiate payment"); - } - } -}; -``` - -**Success Handling**: - -```typescript -useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const paymentStatus = urlParams.get("payment"); - const sessionId = urlParams.get("session_id"); - - if (paymentStatus === "success" && sessionId) { - // Show success modal - setShowSuccessModal(true); - - // Process payment (fallback if webhook hasn't fired yet) - processPaymentMutation.mutate(sessionId); - - // Clean up URL - window.history.replaceState({}, "", window.location.pathname); - } -}, []); -``` - -### Configuration Check - -The frontend checks if payment processing is enabled: - -```typescript -const { data: config } = useConfig(); -const canAddFunds = config?.payment_enabled; -``` - -This is set by the backend based on whether `payment` config exists. - -## Testing - -### Using the Dummy Provider - -The dummy provider is useful for testing without real payment integration: - -```yaml -payment: - dummy: - amount: 10.0 - host_url: "http://localhost:3001" -``` - -The dummy provider: -- Always succeeds immediately -- Doesn't actually charge money -- Credits the configured amount -- Useful for frontend development and testing - -### Testing Real Providers - -1. **Set up test environment**: Use provider's test/sandbox mode (e.g., Stripe test keys) -2. **Configure webhooks**: Point provider webhooks to your development server (use ngrok if needed) -3. **Test scenarios**: - - Successful payment - - Cancelled payment - - Webhook delivery - - Webhook retries - - Race conditions (multiple webhooks) - - Session expiration - -### Webhook Testing - -Use provider CLI tools or services: - -**Stripe**: -```bash -stripe listen --forward-to localhost:3001/admin/api/v1/webhooks/payments -stripe trigger checkout.session.completed -``` - -## Security Considerations - -### Webhook Verification - -Always verify webhook signatures to prevent: -- Spoofed webhooks granting free credits -- Replay attacks -- Man-in-the-middle attacks - -Each provider has its own signature verification method (HMAC, JWT, etc.). - -### Source ID Uniqueness - -The `source_id` field prevents duplicate credits: -- Must be unique per transaction -- Use provider's transaction/session ID -- Database enforces unique constraint -- Handle unique violations gracefully - -### Host URL Configuration - -Always set `host_url` in config rather than trusting request headers: -- Prevents header spoofing -- Ensures correct redirect destination -- Works reliably with proxies -- No dependency on browser headers - -### Authentication - -Payment endpoints require authentication except webhooks: -- Webhooks authenticated via signature verification -- User endpoints require valid session/token -- User can only see their own transactions - -## Troubleshooting - -### Payment not credited after success - -**Symptoms**: User sees success but balance doesn't update - -**Causes**: -1. Webhook not configured or failing -2. Network issues preventing webhook delivery -3. Webhook signature mismatch - -**Solutions**: -1. Check webhook logs in provider dashboard -2. Verify webhook secret in config -3. User can trigger manual processing via frontend (calls PATCH endpoint) -4. Admin can manually create transaction via API - -### Duplicate credits - -**Symptoms**: User credited multiple times for same payment - -**Causes**: -1. Unique constraint not enforced -2. Using non-unique source_id -3. Database migration issue - -**Solutions**: -1. Verify database has unique constraint on `source_id` -2. Check `source_id` is provider's transaction ID, not generated locally -3. Review transaction logs for duplicates - -### Checkout URL missing - -**Symptoms**: User clicks "Add Funds" but nothing happens - -**Causes**: -1. Payment config not set -2. Provider API error -3. Invalid credentials - -**Solutions**: -1. Check `config.payment` is configured -2. Verify API keys are correct -3. Check provider dashboard for API errors -4. Review backend logs for error details - -## Future Enhancements - -Potential improvements to the payment system: - -1. **Multiple providers**: Support multiple active providers with user selection -2. **Currency support**: Multi-currency support with automatic conversion -3. **Subscription billing**: Recurring payment support -4. **Usage-based billing**: Automatic top-up when balance is low -5. **Invoice generation**: PDF invoices for purchases -6. **Payment history**: Detailed payment transaction history separate from credits -7. **Refund support**: Automated refund processing -8. **Payment methods**: Support for alternative payment methods (ACH, wire transfer, etc.) - -## Additional Resources - -- [Stripe Checkout Documentation](https://stripe.com/docs/payments/checkout) -- [Stripe Webhook Testing](https://stripe.com/docs/webhooks/test) -- [PayPal Checkout Integration](https://developer.paypal.com/docs/checkout/) -- [Database Transactions in SQLx](https://github.com/launchbadge/sqlx) diff --git a/dwctl/src/payment_providers/dummy.rs b/dwctl/src/payment_providers/dummy.rs index 1686f307b..7e711ae7a 100644 --- a/dwctl/src/payment_providers/dummy.rs +++ b/dwctl/src/payment_providers/dummy.rs @@ -30,52 +30,44 @@ impl DummyProvider { #[async_trait] impl PaymentProvider for DummyProvider { - async fn create_checkout_session(&self, db_pool: &PgPool, user: &CurrentUser, _cancel_url: &str, success_url: &str) -> Result { - // Generate a unique session ID - let session_id = format!("dummy_session_{}", uuid::Uuid::new_v4()); + async fn create_checkout_session(&self, _db_pool: &PgPool, user: &CurrentUser, _cancel_url: &str, success_url: &str) -> Result { + // Generate a unique session ID that includes the user ID + // This allows us to retrieve the user ID in process_payment_session + let session_id = format!("dummy_session_{}_{}", user.id, uuid::Uuid::new_v4()); - // Immediately create the credit transaction - let mut conn = db_pool.acquire().await?; - let mut credits = Credits::new(&mut conn); - - let request = CreditTransactionCreateDBRequest { - user_id: user.id, - transaction_type: CreditTransactionType::Purchase, - amount: self.amount, - source_id: session_id.clone(), - description: Some("Dummy payment (test)".to_string()), - }; - - credits.create_transaction(&request).await.map_err(|e| { - tracing::error!("Failed to create credit transaction: {:?}", e); - PaymentError::Database(sqlx::Error::RowNotFound) - })?; + // Build success URL with session ID + let redirect_url = success_url.replace("{CHECKOUT_SESSION_ID}", &session_id); - tracing::info!("Dummy provider added {} credits to user {}", self.amount, user.id); + tracing::info!("Dummy provider created checkout session {} for user {}", session_id, user.id); - // Return the success URL since payment is "complete" - Ok(success_url.to_string()) + // Return the success URL - payment is instantly "complete" for dummy provider + Ok(redirect_url) } async fn get_payment_session(&self, session_id: &str) -> Result { // Parse the user ID from the session_id - // Format: dummy_session_{uuid} + // Format: dummy_session_{user_id}_{uuid} if !session_id.starts_with("dummy_session_") { return Err(PaymentError::InvalidData("Invalid dummy session ID format".to_string())); } - // For the dummy provider, we can't reconstruct user_id from session_id alone - // This method is typically called after we already have the transaction in the database - // Return a basic session with dummy data - the actual data comes from the database + // Extract user_id from session_id + let parts: Vec<&str> = session_id.split('_').collect(); + if parts.len() < 4 { + return Err(PaymentError::InvalidData("Invalid dummy session ID format".to_string())); + } + + let user_id = parts[2]; + Ok(PaymentSession { - user_id: "unknown".to_string(), // This will be overridden by database lookup + user_id: user_id.to_string(), amount: self.amount, is_paid: true, // Dummy sessions are always "paid" }) } async fn process_payment_session(&self, db_pool: &PgPool, session_id: &str) -> Result<()> { - // Check if we've already processed this payment + // Fast path: Check if we've already processed this payment let existing = sqlx::query!( r#" SELECT id FROM credits_transactions @@ -88,14 +80,59 @@ impl PaymentProvider for DummyProvider { .await?; if existing.is_some() { - tracing::info!("Transaction for session_id {} already exists, skipping", session_id); + tracing::trace!("Transaction for session_id {} already exists, skipping (fast path)", session_id); return Ok(()); } - // For the dummy provider, the transaction was already created during checkout - // This method serves as a verification that the transaction exists - tracing::info!("Dummy provider verification complete for session {}", session_id); - Ok(()) + // Get payment session details to extract user_id + let payment_session = self.get_payment_session(session_id).await?; + + // Verify payment status + if !payment_session.is_paid { + tracing::trace!("Transaction for session_id {} has not been paid, skipping.", session_id); + return Err(PaymentError::PaymentNotCompleted); + } + + // Create the credit transaction + let mut conn = db_pool.acquire().await?; + let mut credits = Credits::new(&mut conn); + + let user_id: crate::types::UserId = payment_session.user_id.parse().map_err(|e| { + tracing::error!("Failed to parse user ID: {:?}", e); + PaymentError::InvalidData(format!("Invalid user ID: {}", e)) + })?; + + let request = CreditTransactionCreateDBRequest { + user_id, + transaction_type: CreditTransactionType::Purchase, + amount: payment_session.amount, + source_id: session_id.to_string(), + description: Some("Dummy payment (test)".to_string()), + }; + + match credits.create_transaction(&request).await { + Ok(_) => { + tracing::info!("Successfully fulfilled checkout session {} for user {}", session_id, user_id); + Ok(()) + } + Err(crate::db::errors::DbError::UniqueViolation { constraint, .. }) => { + // Check if this is a unique constraint violation on source_id + if constraint.as_deref() == Some("credits_transactions_source_id_unique") { + tracing::trace!( + "Transaction for session_id {} already processed (caught unique constraint violation), returning success (idempotent)", + session_id + ); + Ok(()) + } else { + tracing::error!("Unexpected unique constraint violation: {:?}", constraint); + Err(PaymentError::Database(sqlx::Error::RowNotFound)) + } + } + Err(e) => { + tracing::error!("Failed to create credit transaction: {:?}", e); + Err(PaymentError::Database(sqlx::Error::RowNotFound)) + } + } } async fn validate_webhook(&self, _headers: &axum::http::HeaderMap, _body: &str) -> Result> { @@ -108,3 +145,151 @@ impl PaymentProvider for DummyProvider { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::models::users::Role; + use rust_decimal::Decimal; + use sqlx::PgPool; + + /// Helper to create a test user in the database + async fn create_test_user(pool: &PgPool) -> CurrentUser { + let user = crate::test_utils::create_test_user(pool, Role::StandardUser).await; + + CurrentUser { + id: user.id, + username: user.username, + email: user.email, + display_name: user.display_name, + roles: user.roles, + payment_provider_id: None, + is_admin: false, + avatar_url: None, + } + } + + #[test] + fn test_dummy_provider_creation() { + let provider = DummyProvider::new(Decimal::new(100, 0)); + assert_eq!(provider.amount, Decimal::new(100, 0)); + } + + #[sqlx::test] + async fn test_dummy_full_payment_flow(pool: PgPool) { + let provider = DummyProvider::new(Decimal::new(5000, 2)); // $50.00 + let user = create_test_user(&pool).await; + + let cancel_url = "http://localhost:3001/cost-management?payment=cancelled&session_id={CHECKOUT_SESSION_ID}"; + let success_url = "http://localhost:3001/cost-management?payment=success&session_id={CHECKOUT_SESSION_ID}"; + + // Step 1: Create checkout session + let checkout_url = provider + .create_checkout_session(&pool, &user, cancel_url, success_url) + .await + .unwrap(); + + // Verify it returns the success URL with session_id + assert!(checkout_url.contains("payment=success")); + assert!(checkout_url.contains(&format!("session_id=dummy_session_{}", user.id))); + + // Extract session_id (simulating frontend receiving redirect) + let url = url::Url::parse(&checkout_url).unwrap(); + let query_pairs: std::collections::HashMap<_, _> = url.query_pairs().collect(); + let session_id = query_pairs.get("session_id").unwrap(); + + // Verify NO transaction was created yet (matches Stripe flow) + let count_before = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count_before.count.unwrap(), 0, "Transaction should not exist before processing"); + + // Step 2: Frontend calls backend to process payment + let result = provider.process_payment_session(&pool, session_id).await; + assert!(result.is_ok(), "Payment processing should succeed"); + + // Step 3: Verify transaction was created + let transaction = sqlx::query!( + r#" + SELECT amount, user_id, source_id, description + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(transaction.amount, Decimal::new(5000, 2)); + assert_eq!(transaction.user_id, user.id); + assert_eq!(transaction.description, Some("Dummy payment (test)".to_string())); + } + + #[sqlx::test] + async fn test_dummy_idempotency(pool: PgPool) { + let provider = DummyProvider::new(Decimal::new(100, 0)); + let user = create_test_user(&pool).await; + + let cancel_url = "http://localhost:3001/cost-management?payment=cancelled&session_id={CHECKOUT_SESSION_ID}"; + let success_url = "http://localhost:3001/cost-management?payment=success&session_id={CHECKOUT_SESSION_ID}"; + + // Create checkout session + let checkout_url = provider + .create_checkout_session(&pool, &user, cancel_url, success_url) + .await + .unwrap(); + + // Extract session_id + let url = url::Url::parse(&checkout_url).unwrap(); + let query_pairs: std::collections::HashMap<_, _> = url.query_pairs().collect(); + let session_id = query_pairs.get("session_id").unwrap(); + + // Process payment multiple times (simulating retries, webhook + manual, etc.) + let result1 = provider.process_payment_session(&pool, session_id).await; + let result2 = provider.process_payment_session(&pool, session_id).await; + let result3 = provider.process_payment_session(&pool, session_id).await; + + assert!(result1.is_ok()); + assert!(result2.is_ok()); + assert!(result3.is_ok()); + + // Verify only one transaction exists + let count = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id.to_string() + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count.count.unwrap(), 1, "Should only have one transaction (idempotent)"); + } + + #[test] + fn test_dummy_webhook_not_supported() { + let provider = DummyProvider::new(Decimal::new(100, 0)); + + // Dummy provider doesn't support webhooks + let headers = axum::http::HeaderMap::new(); + let body = "{}"; + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let result = runtime.block_on(provider.validate_webhook(&headers, body)); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), None); // Returns None for unsupported webhooks + } +} diff --git a/dwctl/src/payment_providers/mod.rs b/dwctl/src/payment_providers/mod.rs index addb18474..5a501efb8 100644 --- a/dwctl/src/payment_providers/mod.rs +++ b/dwctl/src/payment_providers/mod.rs @@ -79,7 +79,7 @@ pub struct PaymentSession { } /// Represents a webhook event from a payment provider -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct WebhookEvent { /// Type of event (e.g., "checkout.session.completed") pub event_type: String, diff --git a/dwctl/src/payment_providers/stripe.rs b/dwctl/src/payment_providers/stripe.rs index 2a5190747..6f9d27e2c 100644 --- a/dwctl/src/payment_providers/stripe.rs +++ b/dwctl/src/payment_providers/stripe.rs @@ -87,11 +87,9 @@ impl PaymentProvider for StripeProvider { tracing::info!("Created checkout session {} for user {}", checkout_session.id, user.id); // If we didn't have a customer ID before, save the newly created one - if user.payment_provider_id.is_none() - && let Some(customer) = &checkout_session.customer - { - let customer_id = customer.id().to_string(); - tracing::info!("Saving newly created customer ID {} for user {}", customer_id, user.id); + if user.payment_provider_id.is_none() && checkout_session.customer.is_some() { + let customer_id = checkout_session.customer.as_ref().unwrap().id().to_string(); + tracing::trace!("Saving newly created customer ID {} for user {}", customer_id, user.id); sqlx::query!("UPDATE users SET payment_provider_id = $1 WHERE id = $2", customer_id, user.id) .execute(db_pool) @@ -160,7 +158,7 @@ impl PaymentProvider for StripeProvider { .await?; if existing.is_some() { - tracing::info!("Transaction for session_id {} already exists, skipping (fast path)", session_id); + tracing::trace!("Transaction for session_id {} already exists, skipping (fast path)", session_id); return Ok(()); } @@ -169,7 +167,7 @@ impl PaymentProvider for StripeProvider { // Verify payment status if !payment_session.is_paid { - tracing::info!("Transaction for session_id {} has not been paid, skipping.", session_id); + tracing::trace!("Transaction for session_id {} has not been paid, skipping.", session_id); return Err(PaymentError::PaymentNotCompleted); } @@ -199,7 +197,7 @@ impl PaymentProvider for StripeProvider { // Check if this is a unique constraint violation on source_id // This can happen if two replicas try to process the same payment simultaneously if constraint.as_deref() == Some("credits_transactions_source_id_unique") { - tracing::info!( + tracing::trace!( "Transaction for session_id {} already processed (caught unique constraint violation), returning success (idempotent)", session_id ); @@ -236,7 +234,7 @@ impl PaymentProvider for StripeProvider { PaymentError::InvalidData(format!("Webhook validation failed: {}", e)) })?; - tracing::info!("Validated Stripe webhook event: {:?}", event.type_); + tracing::trace!("Validated Stripe webhook event: {:?}", event.type_); // Convert Stripe event to our generic WebhookEvent let session_id = match &event.data.object { @@ -265,9 +263,100 @@ impl PaymentProvider for StripeProvider { PaymentError::InvalidData("Missing session_id in webhook event".to_string()) })?; - tracing::info!("Processing webhook event {} for session: {}", event.event_type, session_id); + tracing::trace!("Processing webhook event {} for session: {}", event.event_type, session_id); // Use the existing process_payment_session method self.process_payment_session(db_pool, session_id).await } } + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal::Decimal; + use sqlx::PgPool; + use uuid::Uuid; + + /// Helper to create a test user in the database + async fn create_test_user(pool: &PgPool) -> Uuid { + let user = crate::test_utils::create_test_user(pool, crate::api::models::users::Role::StandardUser).await; + user.id + } + + #[test] + fn test_stripe_provider_creation() { + let provider = StripeProvider::new("sk_test_fake".to_string(), "price_fake".to_string(), "whsec_fake".to_string()); + + assert_eq!(provider.api_key, "sk_test_fake"); + assert_eq!(provider.price_id, "price_fake"); + assert_eq!(provider.webhook_secret, "whsec_fake"); + } + + #[sqlx::test] + async fn test_stripe_idempotency_fast_path(pool: PgPool) { + // Test the fast path: transaction already exists in DB + let user_id = create_test_user(&pool).await; + let session_id = "cs_test_fake_session_123"; + + // Create a transaction using the Credits repository (handles balance_after properly) + let mut conn = pool.acquire().await.unwrap(); + let mut credits = crate::db::handlers::Credits::new(&mut conn); + + let request = crate::db::models::credits::CreditTransactionCreateDBRequest { + user_id, + transaction_type: crate::db::models::credits::CreditTransactionType::Purchase, + amount: Decimal::new(5000, 2), + source_id: session_id.to_string(), + description: Some("Test Stripe payment".to_string()), + }; + + credits.create_transaction(&request).await.unwrap(); + + let provider = StripeProvider::new("sk_test_fake".to_string(), "price_fake".to_string(), "whsec_fake".to_string()); + + // Process the same session - should hit fast path and succeed + let result = provider.process_payment_session(&pool, session_id).await; + assert!(result.is_ok(), "Should succeed via fast path (transaction already exists)"); + + // Verify only one transaction exists + let count = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM credits_transactions + WHERE source_id = $1 + "#, + session_id + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(count.count.unwrap(), 1, "Should still have exactly one transaction"); + } + + #[test] + fn test_payment_session_parsing() { + // Test that PaymentSession structure is correct + let session = PaymentSession { + user_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + amount: Decimal::new(5000, 2), + is_paid: true, + }; + + assert_eq!(session.user_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(session.amount, Decimal::new(5000, 2)); + assert!(session.is_paid); + } + + #[test] + fn test_webhook_event_parsing() { + // Test WebhookEvent structure + let event = WebhookEvent { + event_type: "CheckoutSessionCompleted".to_string(), + session_id: Some("cs_test_123".to_string()), + }; + + assert_eq!(event.event_type, "CheckoutSessionCompleted"); + assert_eq!(event.session_id, Some("cs_test_123".to_string())); + } +} diff --git a/tests/authenticated/payment-flow.hurl b/tests/authenticated/payment-flow.hurl new file mode 100644 index 000000000..7b649188a --- /dev/null +++ b/tests/authenticated/payment-flow.hurl @@ -0,0 +1,93 @@ +# Test complete payment flow using dummy provider +# +# PREREQUISITES: This test requires a properly running dwctl server configured with a dummy payment provider. +# +# 1. Start the server with: +# cargo run --bin dwctl +# +# 2. Ensure config.yaml has: +# payment: +# dummy: +# host_url: "http://localhost:3001" +# amount: 100.00 +# +# 3. Or use environment variables when running the server: +# export DWCTL_PAYMENT__DUMMY__HOST_URL="http://localhost:3001" +# export DWCTL_PAYMENT__DUMMY__AMOUNT=100.00 +# +# NOTE: The comprehensive unit tests in dwctl/src/api/handlers/payments.rs and +# dwctl/src/payment_providers/{stripe,dummy}.rs provide full test coverage without +# requiring a running server. Run: cargo test payment + +# Get current user to check initial balance +GET http://localhost:3001/admin/api/v1/users/current?include=billing +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Captures] +initial_balance: jsonpath "$.credit_balance" + + +# Create payment checkout session +POST http://localhost:3001/admin/api/v1/payments +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$.url" exists +jsonpath "$.url" contains "payment=success" +jsonpath "$.url" contains "session_id=dummy_session_" +[Captures] +checkout_url: jsonpath "$.url" +session_id: regex "session_id=([^&]+)" + +# Verify NO transaction was created yet (matches real payment flow) +GET http://localhost:3001/admin/api/v1/users/current?include=billing +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$.credit_balance" == {{initial_balance}} + +# Process payment manually (simulates frontend callback after redirect) +PATCH http://localhost:3001/admin/api/v1/payments/{{session_id}} +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$.message" == "Payment processed successfully" + +# Capture balance after payment processed +GET http://localhost:3001/admin/api/v1/users/current?include=billing +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Captures] +balance_after_checkout: jsonpath "$.credit_balance" + +# Process payment again to verify idempotency +PATCH http://localhost:3001/admin/api/v1/payments/{{session_id}} +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$.message" == "Payment processed successfully" + +# Verify balance unchanged after duplicate processing (idempotent) +GET http://localhost:3001/admin/api/v1/users/current?include=billing +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$.credit_balance" == {{balance_after_checkout}} + +# List transactions to verify payment was recorded +GET http://localhost:3001/admin/api/v1/transactions +[Cookies] +dwctl_session: {{admin_jwt}} +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[0].source_id" exists +jsonpath "$[0].transaction_type" exists +jsonpath "$[0].description" exists