use axum::body::Bytes; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use cm_billing::BillingError; use cm_db::repo::audit::Actor; use hmac::{Hmac, Mac}; use serde::Deserialize; use serde_json::{json, Value}; use crate::{ApiError, AppState, Authed}; #[derive(Deserialize)] pub struct RedeemRequest { code: String, } /// POST /api/credits/redeem — promo redemption (§8.4), once per code. pub async fn redeem( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result, ApiError> { let granted = cm_billing::redeem_promo(&state.pool, user.workspace_id, &body.code) .await .map_err(|e| match e { BillingError::PromoUnavailable => ApiError::Conflict, BillingError::Db(_) => ApiError::Internal, })?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "credits.promo_redeemed", "promo", &body.code, json!({"granted": granted}), ) .await?; Ok(Json(json!({ "granted": granted }))) } /// GET /api/team/usage — the trailing-7-day meter (§8.4). pub async fn usage( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let (tokens_in, tokens_out, credits) = cm_billing::usage_last_7_days(&state.pool, user.workspace_id) .await .map_err(|_| ApiError::Internal)?; Ok(Json(json!({ "tokens_in": tokens_in, "tokens_out": tokens_out, "credits": credits, }))) } /// GET /api/billing/config — whether Buy-credits is available (the button /// is hidden when Stripe is unconfigured; honest degradation). pub async fn billing_config(State(state): State) -> Json { let enabled = state.billing.stripe_secret_key.is_some() && state.billing.stripe_price_id.is_some() && state.billing.stripe_webhook_secret.is_some(); Json(json!({ "buy_credits_enabled": enabled, "credits_per_pack": state.billing.credits_per_pack, })) } /// POST /api/credits/checkout — opens a real Stripe Checkout Session for /// one credit pack and returns its URL. The completed payment arrives /// asynchronously at the webhook below (the credit grant lives there, not /// here — the session URL alone grants nothing). pub async fn checkout( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let (Some(secret), Some(price), Some(base)) = ( state.billing.stripe_secret_key.as_deref(), state.billing.stripe_price_id.as_deref(), state.billing.return_base.as_deref(), ) else { return Err(ApiError::Conflict); // Buy-credits not configured. }; let form = [ ("mode", "payment".to_owned()), ("line_items[0][price]", price.to_owned()), ("line_items[0][quantity]", "1".to_owned()), ( "metadata[workspace_id]", user.workspace_id.as_uuid().to_string(), ), ("success_url", format!("{base}/credits?purchase=success")), ("cancel_url", format!("{base}/credits")), ]; let response: Value = reqwest::Client::new() .post("https://api.stripe.com/v1/checkout/sessions") .basic_auth(secret, Some("")) .form(&form) .send() .await .map_err(|_| ApiError::Internal)? .json() .await .map_err(|_| ApiError::Internal)?; let url = response["url"].as_str().ok_or(ApiError::Internal)?; Ok(Json(json!({ "url": url }))) } /// Verifies Stripe's `t=…,v1=…` signature (HMAC-SHA256 over `.`, /// constant-time) against the webhook secret. fn stripe_signature_valid(secret: &str, header: &str, body: &[u8]) -> bool { let mut timestamp = ""; let mut provided = ""; for part in header.split(',') { match part.split_once('=') { Some(("t", v)) => timestamp = v, Some(("v1", v)) => provided = v, _ => {} } } if timestamp.is_empty() || provided.is_empty() { return false; } let Ok(mut mac) = Hmac::::new_from_slice(secret.as_bytes()) else { return false; }; mac.update(timestamp.as_bytes()); mac.update(b"."); mac.update(body); let expected = hex::encode(mac.finalize().into_bytes()); expected.len() == provided.len() && expected .bytes() .zip(provided.bytes()) .fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 } /// POST /api/billing/stripe — the Stripe webhook. Public by design; /// authenticity is the signature. A verified `checkout.session.completed` /// grants one credit pack, idempotent on the session id. pub async fn stripe_webhook( State(state): State, headers: HeaderMap, body: Bytes, ) -> Result { let secret = state .billing .stripe_webhook_secret .as_deref() .ok_or(StatusCode::NOT_FOUND)?; let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .unwrap_or_default(); if !stripe_signature_valid(secret, signature, &body) { return Err(StatusCode::BAD_REQUEST); } let event: Value = serde_json::from_slice(&body).map_err(|_| StatusCode::BAD_REQUEST)?; if event["type"] != "checkout.session.completed" { return Ok(StatusCode::OK); // Acknowledge unrelated events. } let object = &event["data"]["object"]; let session_id = object["id"].as_str().ok_or(StatusCode::BAD_REQUEST)?; let workspace_id = object["metadata"]["workspace_id"] .as_str() .and_then(|s| s.parse::().ok()) .ok_or(StatusCode::BAD_REQUEST)?; // Idempotent on the session id (Stripe retries; replays must not // double-grant). The unique source string is the dedupe key. let source = format!("stripe:{session_id}"); let already: i64 = sqlx::query_scalar( "SELECT count(*) FROM credit_lots WHERE workspace_id = $1 AND source = $2", ) .bind(workspace_id) .bind(&source) .fetch_one(&state.pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if already > 0 { return Ok(StatusCode::OK); } let workspace = cm_domain::WorkspaceId::from(workspace_id); cm_db::repo::credits::add_lot( &state.pool, workspace, state.billing.credits_per_pack, &source, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; cm_db::repo::audit::append( &state.pool, workspace, Actor::System, "credits.stripe_purchase", "credit_lot", session_id, json!({"credits": state.billing.credits_per_pack}), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) }