All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- Built StoreConfigView.vue for webstore setup - Form fields: store name, description, currency (USD/EUR/GBP) - PayPal credentials (client ID/secret) with encryption support - Sandbox/production mode toggle with warning states - Store enable/disable with validation - Empty state for unconfigured stores - TypeScript StoreConfig interface - Route: /admin/webstore/config (auth required) - API integration: GET/PUT /api/webstore/config - Responsive Tailwind design Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
341 lines
9.9 KiB
Rust
341 lines
9.9 KiB
Rust
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<Sha256>;
|
|
|
|
/// 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<String> {
|
|
#[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<String> {
|
|
#[derive(Serialize)]
|
|
struct CreateOrderRequest {
|
|
intent: String,
|
|
purchase_units: Vec<PurchaseUnit>,
|
|
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<Link>,
|
|
}
|
|
|
|
#[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<String> {
|
|
#[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<bool> {
|
|
// 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<Uuid>,
|
|
pub license_id: Uuid,
|
|
pub amount: f64,
|
|
pub status: String, // pending, completed, failed, refunded
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
}
|