use crate::client::upgrade::Tier;
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.<br><br>
<a href="https://betterctx.com/account">View your account →</a></p>"#,
)
}
pub async fn checkout_cancel() -> impl IntoResponse {
html_page(
"Checkout cancelled — bctx",
"Checkout cancelled",
"#f59e0b",
r#"<p>No charge was made.<br><br><a href="https://betterctx.com/account">Back to your account →</a></p>"#,
)
}
#[derive(Debug, Deserialize)]
pub struct CheckoutQuery {
pub tier: String,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum CheckoutResult {
Redirect { url: String },
Changed { status: String, effective: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BillingAction {
Checkout,
Upgrade,
Downgrade,
Cancel,
ForceFree,
Noop,
RejectEnterprise,
}
fn decide_action(current: Tier, requested: Tier, has_active_sub: bool) -> BillingAction {
if requested == Tier::Enterprise {
return BillingAction::RejectEnterprise;
}
if requested == Tier::Free {
return if has_active_sub {
BillingAction::Cancel
} else if current != Tier::Free {
BillingAction::ForceFree
} else {
BillingAction::Noop
};
}
if !has_active_sub {
return BillingAction::Checkout;
}
match requested.cmp(¤t) {
std::cmp::Ordering::Greater => BillingAction::Upgrade,
std::cmp::Ordering::Less => BillingAction::Downgrade,
std::cmp::Ordering::Equal => BillingAction::Noop,
}
}
fn price_id_for_tier(tier: Tier) -> Result<String, AppError> {
let var = match tier {
Tier::Beacon => "BCTX_STRIPE_BEACON_PRICE",
Tier::Studio => "BCTX_STRIPE_STUDIO_PRICE",
_ => return Err(AppError(anyhow!("no self-serve price for tier {tier:?}"))),
};
std::env::var(var).map_err(|_| AppError(anyhow!("Stripe price ID not configured ({var})")))
}
async fn find_customer_id(
client: &reqwest::Client,
key: &str,
email: &str,
) -> Result<Option<String>, AppError> {
let resp = client
.get("https://api.stripe.com/v1/customers")
.basic_auth(key, Some(""))
.query(&[("email", email), ("limit", "1")])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe response: {e}")))?;
Ok(body["data"][0]["id"].as_str().map(|s| s.to_string()))
}
struct ActiveSub {
sub_id: String,
item_id: String,
schedule_id: Option<String>,
cancel_at_period_end: bool,
current_period_end: Option<i64>,
}
async fn find_active_subscription(
client: &reqwest::Client,
key: &str,
customer_id: &str,
) -> Result<Option<ActiveSub>, AppError> {
let resp = client
.get("https://api.stripe.com/v1/subscriptions")
.basic_auth(key, Some(""))
.query(&[
("customer", customer_id),
("status", "all"),
("limit", "10"),
])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe response: {e}")))?;
const LIVE_STATUSES: [&str; 5] = ["active", "trialing", "past_due", "unpaid", "paused"];
let subs = body["data"].as_array().cloned().unwrap_or_default();
for sub in subs {
let status = sub["status"].as_str().unwrap_or("");
if !LIVE_STATUSES.contains(&status) {
continue;
}
let sub_id = sub["id"].as_str().unwrap_or("").to_string();
let item = &sub["items"]["data"][0];
let item_id = item["id"].as_str().unwrap_or("").to_string();
let price = item["price"]["id"].as_str().unwrap_or("").to_string();
let schedule_id = sub["schedule"].as_str().map(|s| s.to_string());
let cancel_at_period_end = sub["cancel_at_period_end"].as_bool().unwrap_or(false);
let current_period_end = sub["current_period_end"].as_i64();
if !sub_id.is_empty() && !item_id.is_empty() && !price.is_empty() {
return Ok(Some(ActiveSub {
sub_id,
item_id,
schedule_id,
cancel_at_period_end,
current_period_end,
}));
}
}
Ok(None)
}
async fn release_schedule(
client: &reqwest::Client,
key: &str,
schedule_id: &str,
) -> Result<(), AppError> {
let url = format!("https://api.stripe.com/v1/subscription_schedules/{schedule_id}/release");
let resp = client
.post(&url)
.basic_auth(key, Some(""))
.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 schedule release error: {body}")));
}
Ok(())
}
async fn upgrade_subscription(
client: &reqwest::Client,
key: &str,
sub_id: &str,
item_id: &str,
new_price: &str,
) -> Result<(), AppError> {
let url = format!("https://api.stripe.com/v1/subscriptions/{sub_id}");
let resp = client
.post(&url)
.basic_auth(key, Some(""))
.form(&[
("items[0][id]", item_id),
("items[0][price]", new_price),
("proration_behavior", "create_prorations"),
("payment_behavior", "error_if_incomplete"),
])
.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 upgrade error: {body}")));
}
Ok(())
}
async fn downgrade_subscription_at_period_end(
client: &reqwest::Client,
key: &str,
sub_id: &str,
new_price: &str,
) -> Result<(), AppError> {
let create = client
.post("https://api.stripe.com/v1/subscription_schedules")
.basic_auth(key, Some(""))
.form(&[("from_subscription", sub_id)])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
if !create.status().is_success() {
let body = create.text().await.unwrap_or_default();
return Err(AppError(anyhow!("Stripe schedule create error: {body}")));
}
let sched: serde_json::Value = create
.json()
.await
.map_err(|e| AppError(anyhow!("invalid Stripe response: {e}")))?;
let schedule_id = sched["id"]
.as_str()
.ok_or_else(|| AppError(anyhow!("no schedule id in Stripe response")))?
.to_string();
let phase0 = &sched["phases"][0];
let phase0_start = phase0["start_date"]
.as_i64()
.ok_or_else(|| AppError(anyhow!("schedule phase missing start_date")))?;
let phase0_end = phase0["end_date"]
.as_i64()
.ok_or_else(|| AppError(anyhow!("schedule phase missing end_date")))?;
let phase0_price = phase0["items"][0]["price"]
.as_str()
.ok_or_else(|| AppError(anyhow!("schedule phase missing price")))?
.to_string();
let (start_s, end_s) = (phase0_start.to_string(), phase0_end.to_string());
let url = format!("https://api.stripe.com/v1/subscription_schedules/{schedule_id}");
let update = client
.post(&url)
.basic_auth(key, Some(""))
.form(&[
("end_behavior", "release"),
("proration_behavior", "none"),
("phases[0][items][0][price]", phase0_price.as_str()),
("phases[0][start_date]", start_s.as_str()),
("phases[0][end_date]", end_s.as_str()),
("phases[1][items][0][price]", new_price),
("phases[1][iterations]", "1"),
])
.send()
.await
.map_err(|e| AppError(anyhow!("Stripe API request failed: {e}")))?;
if !update.status().is_success() {
let body = update.text().await.unwrap_or_default();
return Err(AppError(anyhow!("Stripe schedule update error: {body}")));
}
Ok(())
}
async fn cancel_subscription_at_period_end(
client: &reqwest::Client,
key: &str,
sub_id: &str,
) -> Result<(), AppError> {
let url = format!("https://api.stripe.com/v1/subscriptions/{sub_id}");
let resp = client
.post(&url)
.basic_auth(key, Some(""))
.form(&[("cancel_at_period_end", "true")])
.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 cancel error: {body}")));
}
Ok(())
}
pub async fn create_checkout(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Query(params): Query<CheckoutQuery>,
) -> Result<impl IntoResponse, AppError> {
if !matches!(
params.tier.as_str(),
"free" | "beacon" | "studio" | "enterprise"
) {
return Err(AppError(anyhow!("unknown tier: {}", params.tier)));
}
let requested = Tier::parse_tier(¶ms.tier);
if requested == Tier::Enterprise {
return Err(AppError(anyhow!(
"Enterprise is not self-serve — please contact sales at https://betterctx.com/contact"
)));
}
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 current = Tier::parse_tier(&user.tier);
let client = reqwest::Client::new();
let customer_id = find_customer_id(&client, &stripe_key, &user.email).await?;
let active = match &customer_id {
Some(cid) => find_active_subscription(&client, &stripe_key, cid).await?,
None => None,
};
match decide_action(current, requested, active.is_some()) {
BillingAction::RejectEnterprise => Err(AppError(anyhow!(
"Enterprise is not self-serve — please contact sales at https://betterctx.com/contact"
))),
BillingAction::Noop => Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "noop".into(),
effective: "none".into(),
}),
)),
BillingAction::ForceFree => {
state
.db
.change_user_tier(&user_id, "free", "downgrade_to_free")?;
tracing::info!(
user_id,
prior_tier = user.tier,
"tier reset to free (no live subscription)"
);
Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "freed".into(),
effective: "immediate".into(),
}),
))
}
BillingAction::Cancel => {
let sub = active.expect("cancel requires an active sub");
if let Some(sched) = &sub.schedule_id {
release_schedule(&client, &stripe_key, sched).await?;
}
cancel_subscription_at_period_end(&client, &stripe_key, &sub.sub_id).await?;
Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "canceled".into(),
effective: "period_end".into(),
}),
))
}
BillingAction::Upgrade => {
let sub = active.expect("upgrade requires an active sub");
if let Some(sched) = &sub.schedule_id {
release_schedule(&client, &stripe_key, sched).await?;
}
let new_price = price_id_for_tier(requested)?;
upgrade_subscription(&client, &stripe_key, &sub.sub_id, &sub.item_id, &new_price)
.await?;
state
.db
.change_user_tier(&user_id, ¶ms.tier, "upgrade")?;
tracing::info!(user_id, tier = params.tier, "subscription upgraded");
Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "upgraded".into(),
effective: "immediate".into(),
}),
))
}
BillingAction::Downgrade => {
let sub = active.expect("downgrade requires an active sub");
if let Some(sched) = &sub.schedule_id {
release_schedule(&client, &stripe_key, sched).await?;
}
let new_price = price_id_for_tier(requested)?;
downgrade_subscription_at_period_end(&client, &stripe_key, &sub.sub_id, &new_price)
.await?;
tracing::info!(
user_id,
tier = params.tier,
"subscription downgrade scheduled"
);
Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "downgraded".into(),
effective: "period_end".into(),
}),
))
}
BillingAction::Checkout => {
let price_id = price_id_for_tier(requested)?;
let success_url = format!(
"{}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
state.base_url
);
let cancel_url = format!("{}/billing/cancel", state.base_url);
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()),
("subscription_data[metadata][user_id]", user_id.as_str()),
("subscription_data[metadata][email]", user.email.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(CheckoutResult::Redirect { 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 frontend_base =
std::env::var("BCTX_FRONTEND_URL").unwrap_or_else(|_| "https://betterctx.com".to_string());
let return_url = format!("{frontend_base}/account");
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 })))
}
#[derive(Debug, Serialize)]
pub struct BillingStatus {
pub tier: String,
pub cancel_at_period_end: bool,
pub period_end: Option<i64>,
}
pub async fn billing_status(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> Result<impl IntoResponse, AppError> {
let user = state
.db
.get_user(&user_id)
.ok_or_else(|| AppError(anyhow!("user not found")))?;
let tier = user.tier.clone();
let stripe_key = match std::env::var("BCTX_STRIPE_SECRET_KEY") {
Ok(k) => k,
Err(_) => {
return Ok(Json(BillingStatus {
tier,
cancel_at_period_end: false,
period_end: None,
}))
}
};
let client = reqwest::Client::new();
let customer_id = find_customer_id(&client, &stripe_key, &user.email)
.await
.unwrap_or(None);
let sub = match &customer_id {
Some(cid) => find_active_subscription(&client, &stripe_key, cid)
.await
.unwrap_or(None),
None => None,
};
let (cancel_at_period_end, period_end) = match sub {
Some(s) => (s.cancel_at_period_end, s.current_period_end),
None => (false, None),
};
Ok(Json(BillingStatus {
tier,
cancel_at_period_end,
period_end,
}))
}
async fn resume_subscription(
client: &reqwest::Client,
key: &str,
sub_id: &str,
) -> Result<(), AppError> {
let url = format!("https://api.stripe.com/v1/subscriptions/{sub_id}");
let resp = client
.post(&url)
.basic_auth(key, Some(""))
.form(&[("cancel_at_period_end", "false")])
.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 resume error: {body}")));
}
Ok(())
}
pub async fn billing_resume(
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 client = reqwest::Client::new();
let customer_id = find_customer_id(&client, &stripe_key, &user.email)
.await?
.ok_or_else(|| AppError(anyhow!("no Stripe customer for this account")))?;
let sub = find_active_subscription(&client, &stripe_key, &customer_id)
.await?
.ok_or_else(|| AppError(anyhow!("no subscription to resume")))?;
resume_subscription(&client, &stripe_key, &sub.sub_id).await?;
tracing::info!(user_id, "subscription cancellation cleared (resumed)");
Ok((
StatusCode::OK,
Json(CheckoutResult::Changed {
status: "resumed".into(),
effective: "immediate".into(),
}),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enterprise_is_rejected() {
assert_eq!(
decide_action(Tier::Beacon, Tier::Enterprise, true),
BillingAction::RejectEnterprise
);
assert_eq!(
decide_action(Tier::Free, Tier::Enterprise, false),
BillingAction::RejectEnterprise
);
}
#[test]
fn first_time_buyer_checks_out() {
assert_eq!(
decide_action(Tier::Free, Tier::Beacon, false),
BillingAction::Checkout
);
}
#[test]
fn paid_tier_without_active_sub_reonboards() {
assert_eq!(
decide_action(Tier::Beacon, Tier::Studio, false),
BillingAction::Checkout
);
}
#[test]
fn moving_up_is_an_upgrade() {
assert_eq!(
decide_action(Tier::Beacon, Tier::Studio, true),
BillingAction::Upgrade
);
}
#[test]
fn moving_down_is_a_downgrade() {
assert_eq!(
decide_action(Tier::Studio, Tier::Beacon, true),
BillingAction::Downgrade
);
}
#[test]
fn dropping_to_free_with_live_sub_cancels() {
assert_eq!(
decide_action(Tier::Studio, Tier::Free, true),
BillingAction::Cancel
);
assert_eq!(
decide_action(Tier::Beacon, Tier::Free, true),
BillingAction::Cancel
);
}
#[test]
fn paid_tier_without_sub_reconciles_to_free() {
assert_eq!(
decide_action(Tier::Beacon, Tier::Free, false),
BillingAction::ForceFree
);
assert_eq!(
decide_action(Tier::Studio, Tier::Free, false),
BillingAction::ForceFree
);
}
#[test]
fn free_with_no_sub_is_noop() {
assert_eq!(
decide_action(Tier::Free, Tier::Free, false),
BillingAction::Noop
);
}
#[test]
fn same_tier_is_noop() {
assert_eq!(
decide_action(Tier::Beacon, Tier::Beacon, true),
BillingAction::Noop
);
}
}