use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; use crate::{ models::error::ApiError, services::payment_processor::PayPalProcessor, AppState, }; pub fn router() -> Router> { Router::new() .route("/:subdomain", get(get_store_info)) .route("/:subdomain/items", get(get_store_items)) .route("/:subdomain/purchase", post(create_purchase_order)) .route("/:subdomain/webhook", post(handle_purchase_webhook)) } #[derive(Serialize)] struct PublicStoreInfo { store_name: String, description: Option, currency: String, enabled: bool, } /// Get public store information by subdomain async fn get_store_info( State(state): State>, Path(subdomain): Path, ) -> Result { // Get license_id from subdomain let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain) .fetch_optional(&state.db) .await?; let license_id = license .ok_or_else(|| ApiError::NotFound("Store not found".to_string()))? .id; // Get store config let config = sqlx::query_as!( PublicStoreInfo, "SELECT store_name, description, currency, enabled FROM store_config WHERE license_id = $1", license_id ) .fetch_optional(&state.db) .await?; if let Some(config) = config { if !config.enabled { return Err(ApiError::NotFound("Store is currently disabled".to_string())); } Ok(Json(config)) } else { Err(ApiError::NotFound("Store not configured".to_string())) } } #[derive(Serialize)] struct PublicStoreItem { id: Uuid, category_name: Option, name: String, description: Option, price: rust_decimal::Decimal, image_url: Option, item_type: String, limit_per_player: Option, } /// Get all items available in the public store async fn get_store_items( State(state): State>, Path(subdomain): Path, ) -> Result { let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain) .fetch_optional(&state.db) .await?; let license_id = license .ok_or_else(|| ApiError::NotFound("Store not found".to_string()))? .id; // Check if store is enabled let enabled = sqlx::query_scalar!("SELECT enabled FROM store_config WHERE license_id = $1", license_id) .fetch_optional(&state.db) .await? .unwrap_or(false); if !enabled { return Err(ApiError::NotFound("Store is currently disabled".to_string())); } type Row = ( Uuid, Option, String, Option, rust_decimal::Decimal, Option, String, Option, ); let rows: Vec = sqlx::query_as( "SELECT i.id, c.name as category_name, i.name, i.description, i.price, i.image_url, i.item_type, i.limit_per_player FROM store_items i LEFT JOIN store_categories c ON c.id = i.category_id WHERE i.license_id = $1 AND i.enabled = true ORDER BY c.display_order, i.name", ) .bind(license_id) .fetch_all(&state.db) .await?; let items: Vec = rows .into_iter() .map(|row| PublicStoreItem { id: row.0, category_name: row.1, name: row.2, description: row.3, price: row.4, image_url: row.5, item_type: row.6, limit_per_player: row.7, }) .collect(); Ok(Json(items)) } #[derive(Deserialize)] struct CreatePurchaseRequest { item_id: Uuid, steam_id: String, player_name: String, } #[derive(Serialize)] struct CreatePurchaseResponse { order_id: String, approval_url: String, } /// Create a PayPal order for a store item purchase async fn create_purchase_order( State(state): State>, Path(subdomain): Path, Json(req): Json, ) -> Result { let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain) .fetch_optional(&state.db) .await?; let license_id = license .ok_or_else(|| ApiError::NotFound("Store not found".to_string()))? .id; // Get store config and check if enabled let store_config = sqlx::query!( "SELECT paypal_client_id, paypal_client_secret, sandbox_mode, enabled FROM store_config WHERE license_id = $1", license_id ) .fetch_optional(&state.db) .await? .ok_or_else(|| ApiError::NotFound("Store not configured".to_string()))?; if !store_config.enabled { return Err(ApiError::BadRequest("Store is currently disabled".to_string())); } // Get item details let item = sqlx::query!( "SELECT name, price FROM store_items WHERE id = $1 AND license_id = $2 AND enabled = true", req.item_id, license_id ) .fetch_optional(&state.db) .await? .ok_or_else(|| ApiError::NotFound("Item not found or disabled".to_string()))?; // Check purchase limit if set if let Some(limit) = sqlx::query_scalar!( "SELECT limit_per_player FROM store_items WHERE id = $1", req.item_id ) .fetch_one(&state.db) .await? { let purchase_count: i64 = sqlx::query_scalar!( "SELECT COUNT(*) FROM store_transactions WHERE item_id = $1 AND steam_id = $2 AND status IN ('paid', 'delivered')", req.item_id, req.steam_id ) .fetch_one(&state.db) .await? .unwrap_or(0); if purchase_count >= limit as i64 { return Err(ApiError::BadRequest("Purchase limit reached for this item".to_string())); } } // Create PayPal processor using store owner's credentials let client_id = store_config.paypal_client_id.ok_or_else(|| { ApiError::Internal("Store PayPal credentials not configured".to_string()) })?; let client_secret = store_config.paypal_client_secret.ok_or_else(|| { ApiError::Internal("Store PayPal credentials not configured".to_string()) })?; // TODO: Decrypt client_secret using encryption service let decrypted_secret = client_secret; // Placeholder - should decrypt let processor = PayPalProcessor::new( client_id.clone(), decrypted_secret, std::env::var("PAYPAL_WEBHOOK_ID").unwrap_or_default(), store_config.sandbox_mode, state.db.clone(), ); // Create PayPal order let price_f64: f64 = item.price.to_string().parse().unwrap_or(0.0); let approval_url = processor .create_order(&item.name, price_f64, Some(req.item_id)) .await?; // Extract order_id from approval_url (it's in the query params) let order_id = approval_url .split("token=") .nth(1) .and_then(|s| s.split('&').next()) .unwrap_or_default() .to_string(); // Store pending transaction sqlx::query!( "INSERT INTO store_transactions (license_id, item_id, steam_id, player_name, paypal_order_id, amount, currency, status) VALUES ($1, $2, $3, $4, $5, $6, 'USD', 'pending')", license_id, req.item_id, req.steam_id, req.player_name, order_id, item.price ) .execute(&state.db) .await?; Ok(Json(CreatePurchaseResponse { order_id: order_id.clone(), approval_url, })) } /// Handle PayPal webhooks for store purchases async fn handle_purchase_webhook( State(state): State>, Path(subdomain): Path, Json(webhook): Json, ) -> Result { tracing::info!("Store purchase webhook for {}: {:?}", subdomain, webhook); let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain) .fetch_optional(&state.db) .await?; let license_id = license .ok_or_else(|| ApiError::NotFound("Store not found".to_string()))? .id; // TODO: Verify webhook signature // Parse event_type let event_type = webhook .get("event_type") .and_then(|v| v.as_str()) .unwrap_or_default(); match event_type { "PAYMENT.CAPTURE.COMPLETED" => { handle_payment_completed(&state, license_id, webhook).await?; } "PAYMENT.CAPTURE.DENIED" | "PAYMENT.CAPTURE.REFUNDED" => { handle_payment_failed(&state, license_id, webhook).await?; } _ => { tracing::info!("Unhandled store webhook event: {}", event_type); } } Ok(StatusCode::OK) } async fn handle_payment_completed( state: &AppState, license_id: Uuid, webhook: serde_json::Value, ) -> Result<(), ApiError> { let order_id = webhook .pointer("/resource/supplementary_data/related_ids/order_id") .and_then(|v| v.as_str()) .ok_or_else(|| ApiError::Internal("Missing order_id in webhook".to_string()))?; // Update transaction status let transaction = sqlx::query!( "UPDATE store_transactions SET status = 'paid', paypal_transaction_id = $1 WHERE paypal_order_id = $2 AND license_id = $3 RETURNING item_id, steam_id", webhook.get("id").and_then(|v| v.as_str()), order_id, license_id ) .fetch_optional(&state.db) .await?; if let Some(txn) = transaction { // Get item delivery commands if let Some(item_id) = txn.item_id { let item = sqlx::query!( "SELECT delivery_commands FROM store_items WHERE id = $1", item_id ) .fetch_one(&state.db) .await?; // Parse delivery commands and send via NATS if let Some(commands_json) = item.delivery_commands { let commands: Vec = serde_json::from_value(commands_json)?; // Replace {steam_id} placeholder in commands let steam_id = txn.steam_id; let replaced_commands: Vec = commands .iter() .map(|cmd| cmd.replace("{steam_id}", &steam_id)) .collect(); // Send commands via NATS to game server if let Some(ref nats) = state.nats { for command in replaced_commands { let subject = format!("corrosion.{}.cmd.console", license_id); let payload = serde_json::json!({ "command": command }); nats.publish(subject, serde_json::to_vec(&payload)?.into()) .await .map_err(|e| ApiError::Internal(format!("NATS publish failed: {}", e)))?; } // Mark as delivered sqlx::query!( "UPDATE store_transactions SET delivered = true, delivered_at = NOW(), status = 'delivered' WHERE paypal_order_id = $1", order_id ) .execute(&state.db) .await?; tracing::info!("Delivered store purchase: order_id={}", order_id); } } } } Ok(()) } async fn handle_payment_failed( state: &AppState, license_id: Uuid, webhook: serde_json::Value, ) -> Result<(), ApiError> { let order_id = webhook .pointer("/resource/supplementary_data/related_ids/order_id") .and_then(|v| v.as_str()) .ok_or_else(|| ApiError::Internal("Missing order_id in webhook".to_string()))?; sqlx::query!( "UPDATE store_transactions SET status = 'failed' WHERE paypal_order_id = $1 AND license_id = $2", order_id, license_id ) .execute(&state.db) .await?; tracing::warn!("Store payment failed: order_id={}", order_id); Ok(()) }