use axum::{
Json,
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::Sha256;
use std::collections::HashMap;
use tracing::{error, info, warn};
use crate::api::handlers::AppState;
use crate::credits;
const PACKS: &[(&str, i32)] = &[("25", 25), ("100", 100), ("500", 500)];
#[derive(Deserialize)]
pub struct CheckoutRequest {
pub pack: String,
}
pub async fn create_checkout(
State(_state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CheckoutRequest>,
) -> (StatusCode, Json<Value>) {
let device_id = match super::handlers::require_device_id_pub(&headers) {
Ok(id) => id,
Err(e) => return e,
};
let pack_credits = match PACKS.iter().find(|(k, _)| *k == req.pack) {
Some((_, c)) => *c,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "unsupported pack",
"valid_packs": PACKS.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
})),
);
}
};
let secret = match std::env::var("STRIPE_SECRET_KEY") {
Ok(s) if !s.is_empty() => s,
_ => {
warn!("/api/checkout hit but STRIPE_SECRET_KEY is unset");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "checkout not configured on this server" })),
);
}
};
let price_env = format!("STRIPE_PRICE_{}", req.pack);
let price_id = match std::env::var(&price_env) {
Ok(s) if s.starts_with("price_") => s,
_ => {
error!("checkout requested pack {} but {} is unset/invalid", req.pack, price_env);
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": format!("pack {} not configured", req.pack) })),
);
}
};
let success_url = std::env::var("CHECKOUT_SUCCESS_URL")
.unwrap_or_else(|_| "https://whisgram.nvnv.app/?checkout=success".to_string());
let cancel_url = std::env::var("CHECKOUT_CANCEL_URL")
.unwrap_or_else(|_| "https://whisgram.nvnv.app/?checkout=cancel".to_string());
let form_params: Vec<(&str, String)> = vec![
("mode", "payment".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", device_id.clone()),
("metadata[device_id]", device_id),
("metadata[pack]", req.pack.clone()),
("metadata[credits]", pack_credits.to_string()),
("line_items[0][price]", price_id),
("line_items[0][quantity]", "1".to_string()),
];
let client = reqwest::Client::new();
let resp = client
.post("https://api.stripe.com/v1/checkout/sessions")
.bearer_auth(&secret)
.form(&form_params)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
error!("Stripe checkout API call failed: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "stripe API unreachable" })),
);
}
};
let status = resp.status();
let body: Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
error!("Stripe checkout response malformed: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "stripe API returned non-JSON" })),
);
}
};
if !status.is_success() {
error!("Stripe checkout returned {}: {}", status, body);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "stripe API rejected request",
"stripe_error": body.get("error"),
})),
);
}
let url = match body.get("url").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => {
error!("Stripe checkout response missing url: {}", body);
return (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "stripe response missing url" })),
);
}
};
info!(
"Created Stripe Checkout session for pack {} ({} credits)",
req.pack, pack_credits
);
(StatusCode::OK, Json(json!({ "checkout_url": url })))
}
pub async fn webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> (StatusCode, Json<Value>) {
let secret = match std::env::var("STRIPE_WEBHOOK_SECRET") {
Ok(s) if !s.is_empty() => s,
_ => {
warn!("/api/webhook/stripe hit but STRIPE_WEBHOOK_SECRET is unset");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "webhook not configured" })),
);
}
};
let sig_header = match headers.get("stripe-signature").and_then(|v| v.to_str().ok()) {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "missing stripe-signature header" })),
);
}
};
if !verify_signature(secret.as_bytes(), &body, sig_header) {
warn!("Stripe webhook signature verification failed");
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid signature" })),
);
}
let event: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
error!("Stripe webhook body wasn't JSON: {}", e);
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "bad json" })));
}
};
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
if event_type != "checkout.session.completed" {
return (StatusCode::OK, Json(json!({ "ignored": event_type })));
}
let session = event.pointer("/data/object");
let metadata = session
.and_then(|s| s.get("metadata"))
.and_then(|m| m.as_object());
let device_id = metadata
.and_then(|m| m.get("device_id"))
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
session
.and_then(|s| s.get("client_reference_id"))
.and_then(|v| v.as_str())
.map(str::to_string)
});
let credits_str = metadata
.and_then(|m| m.get("credits"))
.and_then(|v| v.as_str());
let (device_id, credit_amount) = match (device_id, credits_str.and_then(|s| s.parse::<i32>().ok())) {
(Some(d), Some(c)) if c > 0 => (d, c),
_ => {
error!(
"checkout.session.completed missing device_id / credits in metadata: {}",
event
);
return (
StatusCode::OK,
Json(json!({ "warning": "session lacked metadata" })),
);
}
};
let new_balance = credits::add(&state.credits, &device_id, credit_amount).await;
info!(
"Stripe webhook credited device {} with {} credits (new balance: {})",
device_id, credit_amount, new_balance
);
(StatusCode::OK, Json(json!({ "ok": true, "balance": new_balance })))
}
fn verify_signature(secret: &[u8], body: &[u8], header: &str) -> bool {
let parts: HashMap<&str, &str> = header
.split(',')
.filter_map(|kv| {
let mut it = kv.splitn(2, '=');
Some((it.next()?, it.next()?))
})
.collect();
let timestamp = match parts.get("t") {
Some(t) => *t,
None => return false,
};
let signed_payload = format!("{}.", timestamp);
let mut mac = match Hmac::<Sha256>::new_from_slice(secret) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(signed_payload.as_bytes());
mac.update(body);
let expected = mac.finalize().into_bytes();
for (k, v) in header.split(',').filter_map(|kv| {
let mut it = kv.splitn(2, '=');
Some((it.next()?, it.next()?))
}) {
if k != "v1" {
continue;
}
let Ok(provided) = decode_hex(v) else { continue };
if provided.len() == expected.len() && constant_time_eq(&provided, &expected) {
return true;
}
}
false
}
fn decode_hex(s: &str) -> Result<Vec<u8>, ()> {
if !s.len().is_multiple_of(2) {
return Err(());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
.collect()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}