use crate::server::{AppError, AppState, AuthUser};
use anyhow::anyhow;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
fn html_page(title: &str, heading: &str, color: &str, body: &str) -> impl IntoResponse {
let html = format!(
r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<style>*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:#0f1117;color:#e2e8f0;display:flex;align-items:center;
justify-content:center;min-height:100vh}}
.card{{background:#1a1d27;border:1px solid #2d3148;border-radius:12px;
padding:48px;text-align:center;max-width:420px;width:100%}}
h1{{font-size:24px;color:{color};margin-bottom:12px}}
p{{color:#94a3b8;font-size:15px;line-height:1.6}}
a{{color:#818cf8;text-decoration:none}}a:hover{{text-decoration:underline}}</style>
</head><body><div class="card"><h1>{heading}</h1>{body}</div></body></html>"#
);
axum::response::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(axum::body::Body::from(html))
.unwrap()
}
pub async fn checkout_success() -> impl IntoResponse {
html_page(
"Payment successful — bctx",
"✓ Payment successful",
"#4ade80",
r#"<p>Your plan has been activated. Return to the terminal and run
<code style="background:#0f1117;padding:2px 6px;border-radius:4px">bctx status</code>
to confirm your new tier.<br><br>
<a href="https://betterctx.com">Back to betterctx.com</a></p>"#,
)
}
pub async fn checkout_cancel() -> impl IntoResponse {
html_page(
"Checkout cancelled — bctx",
"Checkout cancelled",
"#f59e0b",
r#"<p>No charge was made. You can upgrade any time with
<code style="background:#0f1117;padding:2px 6px;border-radius:4px">bctx upgrade</code>
or visit <a href="https://betterctx.com">betterctx.com</a>.</p>"#,
)
}
#[derive(Debug, Deserialize)]
pub struct CheckoutQuery {
pub tier: String,
}
#[derive(Debug, Serialize)]
pub struct CheckoutResponse {
pub url: String,
}
pub async fn create_checkout(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Query(params): Query<CheckoutQuery>,
) -> Result<impl IntoResponse, AppError> {
let stripe_key = std::env::var("BCTX_STRIPE_SECRET_KEY").map_err(|_| {
AppError(anyhow!(
"Stripe not configured: missing BCTX_STRIPE_SECRET_KEY"
))
})?;
let price_id = match params.tier.as_str() {
"beacon" => std::env::var("BCTX_STRIPE_BEACON_PRICE"),
"studio" => std::env::var("BCTX_STRIPE_STUDIO_PRICE"),
"enterprise" => std::env::var("BCTX_STRIPE_ENTERPRISE_PRICE"),
other => return Err(AppError(anyhow!("unknown tier: {other}"))),
}
.map_err(|_| {
AppError(anyhow!(
"Stripe price ID not configured for tier '{}'",
params.tier
))
})?;
let user = state
.db
.get_user(&user_id)
.ok_or_else(|| AppError(anyhow!("user not found")))?;
let success_url = format!(
"{}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
state.base_url
);
let cancel_url = format!("{}/billing/cancel", state.base_url);
let client = reqwest::Client::new();
let form = [
("mode", "subscription"),
("customer_email", user.email.as_str()),
("success_url", success_url.as_str()),
("cancel_url", cancel_url.as_str()),
("line_items[0][price]", price_id.as_str()),
("line_items[0][quantity]", "1"),
("metadata[user_id]", user_id.as_str()),
("metadata[price_id]", price_id.as_str()),
];
let resp = client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(&stripe_key, Some(""))
.form(&form)
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError(anyhow!("Stripe API error: {body}")));
}
let session: serde_json::Value = resp
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe response: {e}")))?;
let url = session["url"]
.as_str()
.ok_or_else(|| AppError(anyhow!("no url in Stripe checkout session response")))?
.to_string();
tracing::info!(user_id, tier = params.tier, "checkout session created");
Ok((StatusCode::OK, Json(CheckoutResponse { url })))
}
#[derive(Debug, Serialize)]
pub struct PortalResponse {
pub url: String,
}
pub async fn create_portal(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> Result<impl IntoResponse, AppError> {
let stripe_key = std::env::var("BCTX_STRIPE_SECRET_KEY").map_err(|_| {
AppError(anyhow!(
"Stripe not configured: missing BCTX_STRIPE_SECRET_KEY"
))
})?;
let user = state
.db
.get_user(&user_id)
.ok_or_else(|| AppError(anyhow!("user not found")))?;
let return_url = format!("{}/account", state.base_url);
let client = reqwest::Client::new();
let customer_resp = client
.get("https://api.stripe.com/v1/customers")
.basic_auth(&stripe_key, Some(""))
.query(&[("email", user.email.as_str()), ("limit", "1")])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
let customers: serde_json::Value = customer_resp
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe response: {e}")))?;
let customer_id = customers["data"][0]["id"]
.as_str()
.ok_or_else(|| AppError(anyhow!("no Stripe customer found for this account")))?
.to_string();
let portal_resp = client
.post("https://api.stripe.com/v1/billing_portal/sessions")
.basic_auth(&stripe_key, Some(""))
.form(&[
("customer", customer_id.as_str()),
("return_url", return_url.as_str()),
])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
if !portal_resp.status().is_success() {
let body = portal_resp.text().await.unwrap_or_default();
return Err(AppError(anyhow!("Stripe portal error: {body}")));
}
let session: serde_json::Value = portal_resp
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe portal response: {e}")))?;
let url = session["url"]
.as_str()
.ok_or_else(|| AppError(anyhow!("no url in Stripe portal session response")))?
.to_string();
tracing::info!(user_id, "billing portal session created");
Ok((StatusCode::OK, Json(PortalResponse { url })))
}