- GET /api/team/orgchart — each member grouped with the claws they manage (agents.managed_by); GET /api/team/leaderboard — every claw ranked by its real usage_events rollup (credits/tokens/runs, zeros included). Both tested against real Postgres. - Stripe Buy-credits (the Slack/Clerk integration pattern): [billing] config (stripe keys + price + webhook secret + credits_per_pack); POST /api/credits/checkout opens a real Checkout Session; POST /api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and grants one credit lot, idempotent on the session id; GET /api/billing/config gates the button (honest degradation when unset). Offline tests: signed grant + replay no-double-grant + forged-sig 400 + config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test. - /apps global page support: clawId now optional on connect + directory; absent => workspace-wide connection (app_connections.agent_id NULL) via new connections::list_for_workspace. - ApiError gains a From<sqlx::Error> so inline queries use ? cleanly. cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean. Co-Authored-By: Claude Fable 5 <[email protected]>
209 lines
6.8 KiB
Rust
209 lines
6.8 KiB
Rust
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<AppState>,
|
|
Authed(user): Authed,
|
|
Json(body): Json<RedeemRequest>,
|
|
) -> Result<Json<Value>, 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<AppState>,
|
|
Authed(user): Authed,
|
|
) -> Result<Json<Value>, 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<AppState>) -> Json<Value> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
) -> Result<Json<Value>, 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 `<ts>.<body>`,
|
|
/// 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::<sha2::Sha256>::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<AppState>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
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::<uuid::Uuid>().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)
|
|
}
|