use anyhow::{Context, Result, bail}; use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use hmac::{Hmac, Mac}; use sha2::Sha256; type HmacSha256 = Hmac; /// PayPal payment processor for module purchases and webstore subscriptions pub struct PayPalProcessor { client: Client, client_id: String, client_secret: String, webhook_id: String, sandbox_mode: bool, db: PgPool, } impl PayPalProcessor { pub fn new( client_id: String, client_secret: String, webhook_id: String, sandbox_mode: bool, db: PgPool, ) -> Self { Self { client: Client::new(), client_id, client_secret, webhook_id, sandbox_mode, db, } } fn api_base(&self) -> &str { if self.sandbox_mode { "https://api-m.sandbox.paypal.com" } else { "https://api-m.paypal.com" } } /// Get OAuth access token from PayPal async fn get_access_token(&self) -> Result { #[derive(Deserialize)] struct TokenResponse { access_token: String, } let response = self .client .post(format!("{}/v1/oauth2/token", self.api_base())) .basic_auth(&self.client_id, Some(&self.client_secret)) .form(&[("grant_type", "client_credentials")]) .send() .await .context("Failed to request PayPal access token")?; if !response.status().is_success() { let body = response.text().await.unwrap_or_default(); bail!("PayPal OAuth failed: {}", body); } let token_data: TokenResponse = response .json() .await .context("Failed to parse PayPal token response")?; Ok(token_data.access_token) } /// Create PayPal order for module purchase pub async fn create_module_purchase_order( &self, module_id: Uuid, license_id: Uuid, amount: f64, module_name: &str, ) -> Result { #[derive(Serialize)] struct CreateOrderRequest { intent: String, purchase_units: Vec, application_context: ApplicationContext, } #[derive(Serialize)] struct PurchaseUnit { reference_id: String, description: String, amount: Amount, } #[derive(Serialize)] struct Amount { currency_code: String, value: String, } #[derive(Serialize)] struct ApplicationContext { return_url: String, cancel_url: String, } #[derive(Deserialize)] struct CreateOrderResponse { id: String, links: Vec, } #[derive(Deserialize)] struct Link { rel: String, href: String, } let access_token = self.get_access_token().await?; let request = CreateOrderRequest { intent: "CAPTURE".to_string(), purchase_units: vec![PurchaseUnit { reference_id: format!("module_{}_{}", module_id, license_id), description: format!("Corrosion Module: {}", module_name), amount: Amount { currency_code: "USD".to_string(), value: format!("{:.2}", amount), }, }], application_context: ApplicationContext { return_url: format!("https://panel.corrosionmgmt.com/store/modules/success"), cancel_url: format!("https://panel.corrosionmgmt.com/store/modules/cancel"), }, }; let response = self .client .post(format!("{}/v2/checkout/orders", self.api_base())) .bearer_auth(&access_token) .json(&request) .send() .await .context("Failed to create PayPal order")?; if !response.status().is_success() { let body = response.text().await.unwrap_or_default(); bail!("PayPal order creation failed: {}", body); } let order: CreateOrderResponse = response .json() .await .context("Failed to parse PayPal order response")?; // Find approval URL let approval_url = order .links .iter() .find(|link| link.rel == "approve") .map(|link| link.href.clone()) .context("No approval URL in PayPal response")?; // Store pending order in database self.store_pending_order(&order.id, module_id, license_id, amount) .await?; Ok(approval_url) } /// Capture payment after user approves pub async fn capture_order(&self, order_id: &str) -> Result { #[derive(Deserialize)] struct CaptureResponse { id: String, status: String, } let access_token = self.get_access_token().await?; let response = self .client .post(format!( "{}/v2/checkout/orders/{}/capture", self.api_base(), order_id )) .bearer_auth(&access_token) .send() .await .context("Failed to capture PayPal order")?; if !response.status().is_success() { let body = response.text().await.unwrap_or_default(); bail!("PayPal capture failed: {}", body); } let capture: CaptureResponse = response .json() .await .context("Failed to parse capture response")?; if capture.status != "COMPLETED" { bail!("PayPal capture not completed: {}", capture.status); } Ok(capture.id) } /// Verify PayPal webhook signature (CRITICAL SECURITY) pub fn verify_webhook_signature( &self, transmission_id: &str, timestamp: &str, webhook_id: &str, event_body: &str, cert_url: &str, transmission_sig: &str, ) -> Result { // PayPal webhook verification uses HMAC-SHA256 // For production, should verify cert_url certificate and validate against PayPal root CA // Construct expected signature input let message = format!( "{}|{}|{}|{}", transmission_id, timestamp, webhook_id, event_body ); // For now, basic verification // TODO: Implement full certificate validation in production if webhook_id != self.webhook_id { tracing::warn!("Webhook ID mismatch: expected {}, got {}", self.webhook_id, webhook_id); return Ok(false); } // In production, verify transmission_sig matches HMAC of message // Skipping crypto verification for MVP - rely on webhook ID match + HTTPS Ok(true) } /// Process webhook event from PayPal pub async fn process_webhook_event(&self, event: WebhookEvent) -> Result<()> { match event.event_type.as_str() { "PAYMENT.CAPTURE.COMPLETED" => { self.handle_payment_completed(event).await?; } "PAYMENT.CAPTURE.DENIED" => { self.handle_payment_denied(event).await?; } "BILLING.SUBSCRIPTION.CREATED" => { self.handle_subscription_created(event).await?; } "BILLING.SUBSCRIPTION.CANCELLED" => { self.handle_subscription_cancelled(event).await?; } _ => { tracing::info!("Unhandled PayPal webhook event: {}", event.event_type); } } Ok(()) } async fn handle_payment_completed(&self, event: WebhookEvent) -> Result<()> { // Extract order ID and transaction details // Mark module purchase as complete // Trigger module activation tracing::info!("Payment completed: {:?}", event.resource); // Implementation will call db::modules::record_module_purchase() // and trigger module installation Ok(()) } async fn handle_payment_denied(&self, event: WebhookEvent) -> Result<()> { tracing::warn!("Payment denied: {:?}", event.resource); // Mark order as failed, notify user Ok(()) } async fn handle_subscription_created(&self, event: WebhookEvent) -> Result<()> { // Phase 5: Webstore subscription activation tracing::info!("Subscription created: {:?}", event.resource); Ok(()) } async fn handle_subscription_cancelled(&self, event: WebhookEvent) -> Result<()> { // Phase 5: Webstore deactivation tracing::warn!("Subscription cancelled: {:?}", event.resource); Ok(()) } async fn store_pending_order( &self, order_id: &str, module_id: Uuid, license_id: Uuid, amount: f64, ) -> Result<()> { sqlx::query!( r#" INSERT INTO payment_orders (order_id, module_id, license_id, amount, status) VALUES ($1, $2, $3, $4, 'pending') "#, order_id, module_id, license_id, amount ) .execute(&self.db) .await .context("Failed to store pending order")?; Ok(()) } } /// PayPal webhook event structure #[derive(Debug, Deserialize, Serialize)] pub struct WebhookEvent { pub id: String, pub event_type: String, pub resource: serde_json::Value, pub create_time: String, } /// Payment order status tracking #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PaymentOrder { pub id: Uuid, pub order_id: String, pub module_id: Option, pub license_id: Uuid, pub amount: f64, pub status: String, // pending, completed, failed, refunded pub created_at: chrono::DateTime, }