use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
Json,
};
use chrono::{DateTime, Utc};
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::services::EmailService;
use crate::utils::authenticate;
use crate::AppState;
use crate::handlers::admin::validate_system_admin;
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartKycResponse {
pub session_id: String,
pub redirect_url: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KycStatusApiResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub enforcement_mode: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KycSessionItem {
pub id: String,
pub provider: String,
pub provider_session_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_reason: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserKycResponse {
pub user_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub sessions: Vec<KycSessionItem>,
pub total_sessions: u64,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KycOverrideRequest {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
fn validate_kyc_status(status: &str) -> Result<(), AppError> {
match status {
"none" | "pending" | "verified" | "failed" | "canceled" => Ok(()),
other => Err(AppError::Validation(format!(
"Invalid KYC status '{}'. Expected one of: none, pending, verified, failed, canceled",
other
))),
}
}
fn format_dt(dt: &DateTime<Utc>) -> String {
dt.to_rfc3339()
}
pub async fn start_kyc<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<StartKycResponse>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let kyc_service = state
.kyc_service
.as_ref()
.ok_or_else(|| AppError::NotFound("KYC not available".into()))?;
let result = kyc_service.start_verification(auth_user.user_id).await?;
tracing::info!(
user_id = %auth_user.user_id,
session_id = %result.session_id,
"KYC verification session started"
);
Ok(Json(StartKycResponse {
session_id: result.session_id.to_string(),
redirect_url: result.redirect_url,
}))
}
pub async fn kyc_status<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<KycStatusApiResponse>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let kyc_service = state
.kyc_service
.as_ref()
.ok_or_else(|| AppError::NotFound("KYC not available".into()))?;
let status = kyc_service.get_status(auth_user.user_id).await?;
Ok(Json(KycStatusApiResponse {
status: status.status,
verified_at: status.verified_at.as_ref().map(format_dt),
expires_at: status.expires_at.as_ref().map(format_dt),
enforcement_mode: status.enforcement_mode,
}))
}
pub async fn handle_kyc_webhook<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<StatusCode, AppError> {
let kyc_service = state
.kyc_service
.as_ref()
.ok_or_else(|| AppError::NotFound("KYC not available".into()))?;
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::Validation("Missing Stripe-Signature header".into()))?;
kyc_service.handle_webhook(&body, signature).await?;
Ok(StatusCode::OK)
}
pub async fn get_user_kyc<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
) -> Result<Json<AdminUserKycResponse>, AppError> {
validate_system_admin(&state, &headers).await?;
let kyc_service = state
.kyc_service
.as_ref()
.ok_or_else(|| AppError::NotFound("KYC not available".into()))?;
let user = state
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
use crate::repositories::KycSessionEntity;
let sessions: Vec<KycSessionEntity> = kyc_service.list_sessions(user_id, 20, 0).await?;
let total_sessions: u64 = kyc_service.count_sessions(user_id).await?;
let session_items = sessions
.into_iter()
.map(|s| KycSessionItem {
id: s.id.to_string(),
provider: s.provider,
provider_session_id: s.provider_session_id,
status: s.status,
error_code: s.error_code,
error_reason: s.error_reason,
created_at: format_dt(&s.created_at),
updated_at: format_dt(&s.updated_at),
completed_at: s.completed_at.as_ref().map(format_dt),
})
.collect();
Ok(Json(AdminUserKycResponse {
user_id: user_id.to_string(),
status: user.kyc_status,
verified_at: user.kyc_verified_at.as_ref().map(format_dt),
expires_at: user.kyc_expires_at.as_ref().map(format_dt),
sessions: session_items,
total_sessions,
}))
}
pub async fn override_kyc_status<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
Json(request): Json<KycOverrideRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
validate_kyc_status(&request.status)?;
let _ = state
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
let expires_at: Option<DateTime<Utc>> = match request.expires_at.as_deref() {
None | Some("") => None,
Some(s) => {
let dt = DateTime::parse_from_rfc3339(s)
.map_err(|_| {
AppError::Validation(format!("Invalid expires_at timestamp: '{}'", s))
})?
.with_timezone(&Utc);
Some(dt)
}
};
let verified_at: Option<DateTime<Utc>> = if request.status == "verified" {
Some(Utc::now())
} else {
None
};
state
.user_repo
.set_kyc_status(user_id, &request.status, verified_at, expires_at)
.await?;
tracing::info!(
admin_id = %admin_id,
user_id = %user_id,
status = %request.status,
"Admin KYC status override applied"
);
Ok(Json(serde_json::json!({
"ok": true,
"userId": user_id.to_string(),
"status": request.status,
})))
}