bctx-cloud-core 0.1.6

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
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()
}

/// GET /billing/success — shown after Stripe checkout completes
pub async fn checkout_success() -> impl IntoResponse {
    html_page(
        "Payment successful — bctx",
        "&#10003; 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>"#,
    )
}

/// GET /billing/cancel — shown when user cancels Stripe checkout
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 {
    /// "beacon" | "studio" | "enterprise"
    pub tier: String,
}

#[derive(Debug, Serialize)]
pub struct CheckoutResponse {
    pub url: String,
}

/// POST /billing/checkout?tier=beacon
///
/// Creates a Stripe Checkout Session for the authenticated user and returns
/// the hosted payment URL. The browser should redirect to this URL.
///
/// Requires env vars:
///   BCTX_STRIPE_SECRET_KEY        — Stripe secret key (sk_live_... or sk_test_...)
///   BCTX_STRIPE_BEACON_PRICE      — Stripe price ID for Beacon tier
///   BCTX_STRIPE_STUDIO_PRICE      — Stripe price ID for Studio tier
///   BCTX_STRIPE_ENTERPRISE_PRICE  — Stripe price ID for Enterprise tier
///   BCTX_BASE_URL                 — base URL for success/cancel redirects
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
        ))
    })?;

    // Look up user email
    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,
}

/// POST /billing/portal
///
/// Creates a Stripe Customer Portal session so the user can manage their
/// subscription (cancel, update payment method, view invoices).
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();

    // First: find the Stripe customer ID by email (reqwest encodes query params automatically)
    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();

    // Then: create the portal session
    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 })))
}