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