All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- Replace owner_id → owner_user_id in all queries - Replace auth_token → companion_agent_token in server_connections - Replace l.active → (l.status = 'active') checks using ENUM - Fix AppError → ApiError in all new API files - Add missing imports (Path, PanelAdapter trait) - Fix StoreConfig nullable type mismatches Resolves 122 compilation errors. Only sqlx cache generation remains. Phase 3: EXECUTE complete per V4_WORKFLOW
411 lines
12 KiB
Rust
411 lines
12 KiB
Rust
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<Arc<AppState>> {
|
|
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<String>,
|
|
currency: String,
|
|
enabled: bool,
|
|
}
|
|
|
|
/// Get public store information by subdomain
|
|
async fn get_store_info(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(subdomain): Path<String>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// 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<String>,
|
|
name: String,
|
|
description: Option<String>,
|
|
price: rust_decimal::Decimal,
|
|
image_url: Option<String>,
|
|
item_type: String,
|
|
limit_per_player: Option<i32>,
|
|
}
|
|
|
|
/// Get all items available in the public store
|
|
async fn get_store_items(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(subdomain): Path<String>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
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>,
|
|
String,
|
|
Option<String>,
|
|
rust_decimal::Decimal,
|
|
Option<String>,
|
|
String,
|
|
Option<i32>,
|
|
);
|
|
|
|
let rows: Vec<Row> = 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<PublicStoreItem> = 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<Arc<AppState>>,
|
|
Path(subdomain): Path<String>,
|
|
Json(req): Json<CreatePurchaseRequest>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
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<Arc<AppState>>,
|
|
Path(subdomain): Path<String>,
|
|
Json(webhook): Json<serde_json::Value>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
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<String> = serde_json::from_value(commands_json)?;
|
|
|
|
// Replace {steam_id} placeholder in commands
|
|
let steam_id = txn.steam_id;
|
|
let replaced_commands: Vec<String> = 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(())
|
|
}
|