use askama::Template; use askama_web::WebTemplate; use axum::extract::State; use axum::http::HeaderMap; use axum::response::{IntoResponse, Redirect, Response}; use crate::auth_guard::AuthUser; use crate::flash; use crate::htmx; use crate::page::PageContext; use crate::routes::profile::update_keycloak_user_attributes; use crate::state::AppState; use crate::stripe_util::{StripeEvent, verify_webhook_signature}; #[derive(Template, WebTemplate)] #[template(path = "billing.html")] pub struct BillingTemplate { pub page: PageContext, pub is_admin: bool, pub plan: String, pub is_pro: bool, } pub async fn index( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let plan = user.plan.unwrap_or_else(|| "free".to_string()); let is_pro = plan == "pro"; let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BillingTemplate { page, is_admin: user.is_admin == Some(true), plan, is_pro }; if clear { (axum::response::AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn checkout( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Response { let mut params = stripe::CreateCheckoutSession::new(); params.mode = Some(stripe::CheckoutSessionMode::Subscription); params.success_url = Some("https://my.irc.now/billing?success=1"); params.cancel_url = Some("https://my.irc.now/billing"); params.client_reference_id = Some(&user.sub); params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems { quantity: Some(1), price: Some(state.stripe_price_id.clone()), ..Default::default() }]); if let Some(ref cid) = user.stripe_customer_id { if let Ok(customer_id) = cid.parse() { params.customer = Some(customer_id); } } let mut metadata = std::collections::HashMap::new(); metadata.insert("keycloak_sub".to_string(), user.sub.clone()); params.metadata = Some(metadata); let session = match stripe::CheckoutSession::create(&state.stripe_client, params).await { Ok(s) => s, Err(e) => { tracing::error!("stripe checkout session creation failed: {e}"); if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/billing")], "").into_response(); } return Redirect::to("/billing").into_response(); } }; let url = match session.url { Some(u) => u, None => { tracing::error!("stripe checkout session has no url"); if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/billing")], "").into_response(); } return Redirect::to("/billing").into_response(); } }; if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&url)], "").into_response() } else { Redirect::to(&url).into_response() } } pub async fn portal( State(state): State, AuthUser(user): AuthUser, ) -> Result { let cid = user.stripe_customer_id.ok_or_else(|| { Redirect::to("/billing") })?; let customer_id: stripe::CustomerId = cid.parse().map_err(|_| { Redirect::to("/billing") })?; let mut params = stripe::CreateBillingPortalSession::new(customer_id); params.return_url = Some("https://my.irc.now/billing"); let session = stripe::BillingPortalSession::create(&state.stripe_client, params) .await .map_err(|e| { tracing::error!("stripe portal session creation failed: {e}"); Redirect::to("/billing") })?; Ok(Redirect::to(&session.url)) } pub async fn webhook( State(state): State, headers: HeaderMap, body: String, ) -> &'static str { let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if !verify_webhook_signature(&body, signature, &state.stripe_webhook_secret) { tracing::warn!("invalid stripe webhook signature"); return "invalid signature"; } let event: StripeEvent = match serde_json::from_str(&body) { Ok(e) => e, Err(e) => { tracing::error!("failed to parse stripe event: {e}"); return "parse error"; } }; match event.event_type.as_str() { "checkout.session.completed" => { let customer = event.data.object.get("customer") .and_then(|v| v.as_str()); let sub = event.data.object.get("metadata") .and_then(|m| m.get("keycloak_sub")) .and_then(|v| v.as_str()); if let (Some(customer), Some(sub)) = (customer, sub) { let result = sqlx::query( "UPDATE users SET plan = 'pro', stripe_customer_id = $1 WHERE keycloak_sub = $2 AND plan != 'pro'", ) .bind(customer) .bind(sub) .execute(&state.db) .await; match result { Ok(r) if r.rows_affected() == 0 => { tracing::info!("webhook for {sub}: already pro, skipping"); } Ok(_) => { if let Err(e) = update_keycloak_user_attributes(&state, sub, "pro", true).await { tracing::warn!("keycloak attribute sync after upgrade failed: {e}"); } crate::events::record( &state.db, sub, "plan_upgrade", Some(serde_json::json!({"from": "free", "to": "pro"})), ).await; } Err(e) => tracing::error!("failed to upgrade user {sub}: {e}"), } } } "customer.subscription.deleted" => { let customer = event.data.object.get("customer") .and_then(|v| v.as_str()); if let Some(customer) = customer { let sub: Option = sqlx::query_scalar( "UPDATE users SET plan = 'free' WHERE stripe_customer_id = $1 RETURNING keycloak_sub", ) .bind(customer) .fetch_optional(&state.db) .await .unwrap_or(None); if let Some(sub) = sub { if let Err(e) = update_keycloak_user_attributes(&state, &sub, "free", true).await { tracing::warn!("keycloak attribute sync after downgrade failed: {e}"); } crate::events::record( &state.db, &sub, "plan_downgrade", Some(serde_json::json!({"from": "pro", "to": "free"})), ).await; } } } _ => {} } "ok" }