526 lines
18 KiB
Rust
526 lines
18 KiB
Rust
use axum::{
|
|
extract::{Json, Query},
|
|
http::{HeaderMap, StatusCode},
|
|
response::Json as ResponseJson,
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use dotenv::dotenv;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{collections::HashMap, env};
|
|
use tower::ServiceBuilder;
|
|
use tower_http::{
|
|
cors::{Any, CorsLayer},
|
|
services::ServeDir,
|
|
};
|
|
use tracing::{info, warn, error};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CreatePaymentIntentRequest {
|
|
company_name: String,
|
|
company_type: String,
|
|
company_email: Option<String>,
|
|
company_phone: Option<String>,
|
|
company_website: Option<String>,
|
|
company_address: Option<String>,
|
|
company_industry: Option<String>,
|
|
company_purpose: Option<String>,
|
|
fiscal_year_end: Option<String>,
|
|
shareholders: Option<String>,
|
|
payment_plan: String,
|
|
agreements: Vec<String>,
|
|
final_agreement: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CreateResidentPaymentIntentRequest {
|
|
resident_name: String,
|
|
email: String,
|
|
phone: Option<String>,
|
|
date_of_birth: Option<String>,
|
|
nationality: Option<String>,
|
|
passport_number: Option<String>,
|
|
address: Option<String>,
|
|
payment_plan: String,
|
|
amount: f64,
|
|
#[serde(rename = "type")]
|
|
request_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct CreatePaymentIntentResponse {
|
|
client_secret: String,
|
|
payment_intent_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorResponse {
|
|
error: String,
|
|
details: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WebhookQuery {
|
|
#[serde(rename = "payment_intent")]
|
|
payment_intent_id: Option<String>,
|
|
#[serde(rename = "payment_intent_client_secret")]
|
|
client_secret: Option<String>,
|
|
}
|
|
|
|
// Calculate pricing based on company type and payment plan
|
|
fn calculate_amount(company_type: &str, payment_plan: &str) -> Result<i64, String> {
|
|
let base_amounts = match company_type {
|
|
"Single FZC" => (20, 20), // (setup, monthly)
|
|
"Startup FZC" => (50, 50),
|
|
"Growth FZC" => (1000, 100),
|
|
"Global FZC" => (2000, 200),
|
|
"Cooperative FZC" => (2000, 200),
|
|
_ => return Err("Invalid company type".to_string()),
|
|
};
|
|
|
|
let (setup_fee, monthly_fee) = base_amounts;
|
|
let twin_fee = 2; // ZDFZ Twin fee
|
|
let total_monthly = monthly_fee + twin_fee;
|
|
|
|
let amount_cents = match payment_plan {
|
|
"monthly" => (setup_fee + total_monthly) * 100,
|
|
"yearly" => (setup_fee + (total_monthly * 12 * 80 / 100)) * 100, // 20% discount
|
|
"two_year" => (setup_fee + (total_monthly * 24 * 60 / 100)) * 100, // 40% discount
|
|
_ => return Err("Invalid payment plan".to_string()),
|
|
};
|
|
|
|
Ok(amount_cents as i64)
|
|
}
|
|
|
|
// Create payment intent with Stripe
|
|
async fn create_payment_intent(
|
|
Json(payload): Json<CreatePaymentIntentRequest>,
|
|
) -> Result<ResponseJson<CreatePaymentIntentResponse>, (StatusCode, ResponseJson<ErrorResponse>)> {
|
|
info!("Creating payment intent for company: {}", payload.company_name);
|
|
|
|
// Validate required fields
|
|
if !payload.final_agreement {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Final agreement must be accepted".to_string(),
|
|
details: None,
|
|
}),
|
|
));
|
|
}
|
|
|
|
// Calculate amount based on company type and payment plan
|
|
let amount = match calculate_amount(&payload.company_type, &payload.payment_plan) {
|
|
Ok(amount) => amount,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: e,
|
|
details: None,
|
|
}),
|
|
));
|
|
}
|
|
};
|
|
|
|
// Get Stripe secret key from environment
|
|
let stripe_secret_key = env::var("STRIPE_SECRET_KEY").map_err(|_| {
|
|
error!("STRIPE_SECRET_KEY not found in environment");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Server configuration error".to_string(),
|
|
details: Some("Stripe not configured".to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
// Create Stripe client
|
|
let client = reqwest::Client::new();
|
|
|
|
// Prepare payment intent data
|
|
let mut form_data = HashMap::new();
|
|
form_data.insert("amount", amount.to_string());
|
|
form_data.insert("currency", "usd".to_string());
|
|
form_data.insert("automatic_payment_methods[enabled]", "true".to_string());
|
|
|
|
// Add metadata
|
|
form_data.insert("metadata[company_name]", payload.company_name.clone());
|
|
form_data.insert("metadata[company_type]", payload.company_type.clone());
|
|
form_data.insert("metadata[payment_plan]", payload.payment_plan.clone());
|
|
if let Some(email) = &payload.company_email {
|
|
form_data.insert("metadata[company_email]", email.clone());
|
|
}
|
|
|
|
// Add description
|
|
let description = format!(
|
|
"Company Registration: {} ({})",
|
|
payload.company_name, payload.company_type
|
|
);
|
|
form_data.insert("description", description);
|
|
|
|
// Call Stripe API
|
|
let response = client
|
|
.post("https://api.stripe.com/v1/payment_intents")
|
|
.header("Authorization", format!("Bearer {}", stripe_secret_key))
|
|
.form(&form_data)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to call Stripe API: {}", e);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Failed to create payment intent".to_string(),
|
|
details: Some(e.to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
error!("Stripe API error: {}", error_text);
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Stripe payment intent creation failed".to_string(),
|
|
details: Some(error_text),
|
|
}),
|
|
));
|
|
}
|
|
|
|
let stripe_response: serde_json::Value = response.json().await.map_err(|e| {
|
|
error!("Failed to parse Stripe response: {}", e);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid response from payment processor".to_string(),
|
|
details: Some(e.to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let client_secret = stripe_response["client_secret"]
|
|
.as_str()
|
|
.ok_or_else(|| {
|
|
error!("No client_secret in Stripe response");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid payment intent response".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let payment_intent_id = stripe_response["id"]
|
|
.as_str()
|
|
.ok_or_else(|| {
|
|
error!("No id in Stripe response");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid payment intent response".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
info!("Payment intent created successfully: {}", payment_intent_id);
|
|
|
|
Ok(ResponseJson(CreatePaymentIntentResponse {
|
|
client_secret: client_secret.to_string(),
|
|
payment_intent_id: payment_intent_id.to_string(),
|
|
}))
|
|
}
|
|
|
|
// Create payment intent for resident registration
|
|
async fn create_resident_payment_intent(
|
|
Json(payload): Json<CreateResidentPaymentIntentRequest>,
|
|
) -> Result<ResponseJson<CreatePaymentIntentResponse>, (StatusCode, ResponseJson<ErrorResponse>)> {
|
|
info!("Creating payment intent for resident: {}", payload.resident_name);
|
|
|
|
// Convert amount from dollars to cents
|
|
let amount_cents = (payload.amount * 100.0) as i64;
|
|
|
|
// Get Stripe secret key from environment
|
|
let stripe_secret_key = env::var("STRIPE_SECRET_KEY").map_err(|_| {
|
|
error!("STRIPE_SECRET_KEY not found in environment");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Server configuration error".to_string(),
|
|
details: Some("Stripe not configured".to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
// Create Stripe client
|
|
let client = reqwest::Client::new();
|
|
|
|
// Prepare payment intent data
|
|
let mut form_data = HashMap::new();
|
|
form_data.insert("amount", amount_cents.to_string());
|
|
form_data.insert("currency", "usd".to_string());
|
|
form_data.insert("automatic_payment_methods[enabled]", "true".to_string());
|
|
|
|
// Add metadata
|
|
form_data.insert("metadata[resident_name]", payload.resident_name.clone());
|
|
form_data.insert("metadata[email]", payload.email.clone());
|
|
form_data.insert("metadata[payment_plan]", payload.payment_plan.clone());
|
|
form_data.insert("metadata[type]", payload.request_type.clone());
|
|
if let Some(phone) = &payload.phone {
|
|
form_data.insert("metadata[phone]", phone.clone());
|
|
}
|
|
if let Some(nationality) = &payload.nationality {
|
|
form_data.insert("metadata[nationality]", nationality.clone());
|
|
}
|
|
|
|
// Add description
|
|
let description = format!(
|
|
"Resident Registration: {} ({})",
|
|
payload.resident_name, payload.payment_plan
|
|
);
|
|
form_data.insert("description", description);
|
|
|
|
// Call Stripe API
|
|
let response = client
|
|
.post("https://api.stripe.com/v1/payment_intents")
|
|
.header("Authorization", format!("Bearer {}", stripe_secret_key))
|
|
.form(&form_data)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to call Stripe API: {}", e);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Failed to create payment intent".to_string(),
|
|
details: Some(e.to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
error!("Stripe API error: {}", error_text);
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Stripe payment intent creation failed".to_string(),
|
|
details: Some(error_text),
|
|
}),
|
|
));
|
|
}
|
|
|
|
let stripe_response: serde_json::Value = response.json().await.map_err(|e| {
|
|
error!("Failed to parse Stripe response: {}", e);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid response from payment processor".to_string(),
|
|
details: Some(e.to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let client_secret = stripe_response["client_secret"]
|
|
.as_str()
|
|
.ok_or_else(|| {
|
|
error!("No client_secret in Stripe response");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid payment intent response".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let payment_intent_id = stripe_response["id"]
|
|
.as_str()
|
|
.ok_or_else(|| {
|
|
error!("No id in Stripe response");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid payment intent response".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
info!("Resident payment intent created successfully: {}", payment_intent_id);
|
|
|
|
Ok(ResponseJson(CreatePaymentIntentResponse {
|
|
client_secret: client_secret.to_string(),
|
|
payment_intent_id: payment_intent_id.to_string(),
|
|
}))
|
|
}
|
|
|
|
// Handle Stripe webhooks
|
|
async fn handle_webhook(
|
|
headers: HeaderMap,
|
|
body: String,
|
|
) -> Result<StatusCode, (StatusCode, ResponseJson<ErrorResponse>)> {
|
|
let stripe_signature = headers
|
|
.get("stripe-signature")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| {
|
|
warn!("Missing Stripe signature header");
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Missing signature".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let _webhook_secret = env::var("STRIPE_WEBHOOK_SECRET").map_err(|_| {
|
|
error!("STRIPE_WEBHOOK_SECRET not found in environment");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Webhook not configured".to_string(),
|
|
details: None,
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
// In a real implementation, you would verify the webhook signature here
|
|
// For now, we'll just log the event
|
|
info!("Received webhook with signature: {}", stripe_signature);
|
|
info!("Webhook body: {}", body);
|
|
|
|
// Parse the webhook event
|
|
let event: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
|
error!("Failed to parse webhook body: {}", e);
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
ResponseJson(ErrorResponse {
|
|
error: "Invalid webhook body".to_string(),
|
|
details: Some(e.to_string()),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let event_type = event["type"].as_str().unwrap_or("unknown");
|
|
info!("Processing webhook event: {}", event_type);
|
|
|
|
match event_type {
|
|
"payment_intent.succeeded" => {
|
|
let payment_intent = &event["data"]["object"];
|
|
let payment_intent_id = payment_intent["id"].as_str().unwrap_or("unknown");
|
|
info!("Payment succeeded: {}", payment_intent_id);
|
|
|
|
// Here you would typically:
|
|
// 1. Update your database to mark the company as registered
|
|
// 2. Send confirmation emails
|
|
// 3. Trigger any post-payment workflows
|
|
}
|
|
"payment_intent.payment_failed" => {
|
|
let payment_intent = &event["data"]["object"];
|
|
let payment_intent_id = payment_intent["id"].as_str().unwrap_or("unknown");
|
|
warn!("Payment failed: {}", payment_intent_id);
|
|
|
|
// Handle failed payment
|
|
}
|
|
_ => {
|
|
info!("Unhandled webhook event type: {}", event_type);
|
|
}
|
|
}
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
// Payment success redirect
|
|
async fn payment_success(Query(params): Query<WebhookQuery>) -> axum::response::Redirect {
|
|
info!("Payment success page accessed");
|
|
|
|
if let Some(ref payment_intent_id) = params.payment_intent_id {
|
|
info!("Payment intent ID: {}", payment_intent_id);
|
|
|
|
// In a real implementation, you would:
|
|
// 1. Verify the payment intent with Stripe
|
|
// 2. Get the company ID from your database
|
|
// 3. Redirect to the success page with the actual company ID
|
|
|
|
// For now, we'll use a mock company ID (in real app, get from database)
|
|
let company_id = 1; // This should be retrieved from your database based on payment_intent_id
|
|
|
|
axum::response::Redirect::to(&format!("/entities/register/success/{}", company_id))
|
|
} else {
|
|
// If no payment intent ID, redirect to entities page
|
|
axum::response::Redirect::to("/entities")
|
|
}
|
|
}
|
|
|
|
// Payment failure redirect
|
|
async fn payment_failure() -> axum::response::Redirect {
|
|
info!("Payment failure page accessed");
|
|
axum::response::Redirect::to("/entities/register/failure")
|
|
}
|
|
|
|
// Health check endpoint
|
|
async fn health_check() -> ResponseJson<serde_json::Value> {
|
|
ResponseJson(serde_json::json!({
|
|
"status": "healthy",
|
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
"service": "freezone-platform-server"
|
|
}))
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// Load environment variables
|
|
dotenv().ok();
|
|
|
|
// Initialize tracing
|
|
tracing_subscriber::fmt::init();
|
|
|
|
// Check required environment variables
|
|
let required_vars = ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"];
|
|
for var in &required_vars {
|
|
if env::var(var).is_err() {
|
|
warn!("Environment variable {} not set", var);
|
|
}
|
|
}
|
|
|
|
// Build the application router
|
|
let app = Router::new()
|
|
// API routes
|
|
.route("/api/health", get(health_check))
|
|
.route("/company/create-payment-intent", post(create_payment_intent))
|
|
.route("/resident/create-payment-intent", post(create_resident_payment_intent))
|
|
.route("/company/payment-success", get(payment_success))
|
|
.route("/company/payment-failure", get(payment_failure))
|
|
.route("/webhooks/stripe", post(handle_webhook))
|
|
// Serve static files (WASM, HTML, CSS, JS)
|
|
.nest_service("/", ServeDir::new("."))
|
|
// Add middleware
|
|
.layer(
|
|
ServiceBuilder::new()
|
|
.layer(
|
|
CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any),
|
|
)
|
|
);
|
|
|
|
// Get server configuration from environment
|
|
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
|
let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
|
|
let addr = format!("{}:{}", host, port);
|
|
|
|
info!("Starting server on {}", addr);
|
|
info!("Health check: http://{}/api/health", addr);
|
|
info!("Payment endpoint: http://{}/company/create-payment-intent", addr);
|
|
|
|
// Start the server
|
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
axum::serve(listener, app).await?;
|
|
|
|
Ok(())
|
|
} |