Initial commit: Full Crawl API implementation
This commit is contained in:
146
crates/api/src/routes/stripe.rs
Normal file
146
crates/api/src/routes/stripe.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
};
|
||||
use db::repos::{subscriptions, users};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{middleware::jwt::JwtAuth, state::AppState};
|
||||
use axum::Extension;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCheckoutRequest {
|
||||
pub price_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckoutResponse {
|
||||
pub checkout_url: String,
|
||||
}
|
||||
|
||||
pub async fn create_checkout(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<JwtAuth>,
|
||||
Json(body): Json<CreateCheckoutRequest>,
|
||||
) -> Result<Json<CheckoutResponse>, StatusCode> {
|
||||
let stripe_secret = std::env::var("STRIPE_SECRET_KEY").map_err(|_| StatusCode::NOT_IMPLEMENTED)?;
|
||||
|
||||
let user = users::find_by_id(&state.db, auth.user_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Create Stripe customer via HTTP API directly (simpler than SDK for MVP)
|
||||
let client = reqwest::Client::new();
|
||||
let customer_res = client
|
||||
.post("https://api.stripe.com/v1/customers")
|
||||
.basic_auth(&stripe_secret, Some(""))
|
||||
.form(&[("email", &user.email)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let customer_data: serde_json::Value = customer_res.json().await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let customer_id = customer_data["id"].as_str().unwrap_or("");
|
||||
|
||||
let success_url = std::env::var("STRIPE_SUCCESS_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000/dashboard?success=true".to_string());
|
||||
let cancel_url = std::env::var("STRIPE_CANCEL_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000/dashboard?canceled=true".to_string());
|
||||
|
||||
let session_res = client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(&stripe_secret, Some(""))
|
||||
.form(&[
|
||||
("customer", customer_id),
|
||||
("success_url", &success_url),
|
||||
("cancel_url", &cancel_url),
|
||||
("mode", "subscription"),
|
||||
("line_items[0][price]", &body.price_id),
|
||||
("line_items[0][quantity]", "1"),
|
||||
("metadata[user_id]", &auth.user_id.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let session_data: serde_json::Value = session_res.json().await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let url = session_data["url"].as_str().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(CheckoutResponse { checkout_url: url.to_string() }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StripeWebhook {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub data: StripeEventData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StripeEventData {
|
||||
pub object: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let stripe_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
||||
|
||||
// Verify webhook signature if configured
|
||||
if !stripe_secret.is_empty() {
|
||||
let sig = headers
|
||||
.get("stripe-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// In production, verify signature using Stripe library
|
||||
// For MVP, we log and process
|
||||
tracing::info!("Webhook signature: {}", sig);
|
||||
}
|
||||
|
||||
let event: serde_json::Value = serde_json::from_str(&body).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"checkout.session.completed" => {
|
||||
if let Some(metadata) = event["data"]["object"]["metadata"].as_object() {
|
||||
if let Some(user_id_str) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(user_id) = uuid::Uuid::parse_str(user_id_str) {
|
||||
let customer_id = event["data"]["object"]["customer"].as_str().unwrap_or("");
|
||||
let subscription_id = event["data"]["object"]["subscription"].as_str().unwrap_or("");
|
||||
let _ = subscriptions::create_or_update(
|
||||
&state.db,
|
||||
user_id,
|
||||
Some(customer_id),
|
||||
Some(subscription_id),
|
||||
None,
|
||||
"active",
|
||||
"paid",
|
||||
).await;
|
||||
tracing::info!("Subscription activated for user {}", user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"invoice.payment_succeeded" => {
|
||||
tracing::info!("Invoice payment succeeded");
|
||||
}
|
||||
"customer.subscription.deleted" => {
|
||||
if let Some(sub_id) = event["data"]["object"]["id"].as_str() {
|
||||
let _ = subscriptions::update_status(&state.db, sub_id, "canceled").await;
|
||||
tracing::info!("Subscription {} canceled", sub_id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Reference in New Issue
Block a user