nxtgauge-backend-rust/apps/payments/src/ai_credits.rs
2026-06-26 21:07:36 +02:00

401 lines
12 KiB
Rust

use crate::AppState;
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use hmac::Mac;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct CreateAiCreditOrderRequest {
package_id: Uuid,
}
#[derive(Debug, Deserialize, Serialize)]
struct VerifyAiCreditOrderRequest {
order_id: String,
payment_id: String,
signature: Option<String>,
}
#[derive(Debug, Serialize, FromRow)]
struct AiCreditPackageRow {
id: Uuid,
name: String,
description: Option<String>,
credits: i32,
price_inr: i32,
}
pub fn ai_credits_router() -> Router<AppState> {
Router::new()
.route("/ai-credits", get(list_ai_credit_packages))
.route("/ai-credits/order", post(create_ai_credit_order))
.route("/ai-credits/verify", post(verify_ai_credit_order))
}
async fn list_ai_credit_packages(State(state): State<AppState>) -> impl IntoResponse {
let rows = sqlx::query_as::<_, AiCreditPackageRow>(
r#"
SELECT id, name, description, credits, price_inr
FROM ai_credit_packages
WHERE is_active = true
ORDER BY price_inr ASC
"#,
)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => (StatusCode::OK, Json(serde_json::json!({ "packages": rows }))).into_response(),
Err(e) => {
tracing::error!("Failed to list AI credit packages: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response()
}
}
}
async fn create_ai_credit_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateAiCreditOrderRequest>,
) -> impl IntoResponse {
let package = match sqlx::query_as::<_, AiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true",
)
.bind(payload.package_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(package)) => package,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Invalid or inactive AI credit package" })),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to fetch AI credit package: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
let gateway_config = match crate::resolve_razorpay_config(&state).await {
Ok(config) => config,
Err((status, message)) => {
return (status, Json(serde_json::json!({ "error": message }))).into_response();
}
};
let resp = match state
.client
.post(format!("{}/v1/orders", gateway_config.base_url.trim_end_matches('/')))
.basic_auth(&gateway_config.key_id, Some(&gateway_config.key_secret))
.json(&serde_json::json!({
"amount": package.price_inr,
"currency": "INR",
"receipt": format!("ai_{}", Uuid::new_v4().simple()),
"notes": {
"package_id": package.id.to_string(),
"user_id": auth.user_id.to_string(),
"package_type": "AI_CREDITS"
}
}))
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("Razorpay error creating AI credit order: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Payment gateway error" })),
)
.into_response();
}
};
let status = resp.status();
let body: serde_json::Value = match resp.json().await {
Ok(body) => body,
Err(e) => {
tracing::error!("Failed to parse Razorpay order response: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Invalid payment gateway response" })),
)
.into_response();
}
};
if status == StatusCode::UNAUTHORIZED {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "Razorpay authentication failed" })),
)
.into_response();
}
if !status.is_success() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": body.get("message").and_then(|m| m.as_str()).unwrap_or("Order creation failed")
})),
)
.into_response();
}
let order_id = body
.get("id")
.or_else(|| body.get("order_id"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Err(e) = sqlx::query(
r#"
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, 0, 'PENDING')
"#,
)
.bind(auth.user_id)
.bind(package.id)
.bind(&order_id)
.bind(package.price_inr)
.execute(&state.pool)
.await
{
tracing::error!("Failed to record AI credit payment: {}", e);
}
(
StatusCode::OK,
Json(serde_json::json!({
"order_id": order_id,
"amount": package.price_inr,
"currency": "INR",
"credits": package.credits,
"status": body.get("status").and_then(|v| v.as_str()).unwrap_or("created"),
})),
)
.into_response()
}
async fn verify_ai_credit_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<VerifyAiCreditOrderRequest>,
) -> impl IntoResponse {
let gateway_config = match crate::resolve_razorpay_config(&state).await {
Ok(config) => config,
Err((status, message)) => {
return (status, Json(serde_json::json!({ "error": message }))).into_response();
}
};
let signature = match payload.signature.clone() {
Some(signature) => signature,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "razorpay_signature is required" })),
)
.into_response();
}
};
let provided_signature = match hex::decode(signature) {
Ok(sig) => sig,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Invalid signature format" })),
)
.into_response();
}
};
let mut mac = match hmac::Hmac::<sha2::Sha256>::new_from_slice(gateway_config.key_secret.as_bytes()) {
Ok(mac) => mac,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Unable to initialize signature verifier" })),
)
.into_response();
}
};
mac.update(format!("{}|{}", payload.order_id, payload.payment_id).as_bytes());
if mac.verify_slice(&provided_signature).is_err() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Signature mismatch" })),
)
.into_response();
}
let payment = match sqlx::query_as::<_, crate::PaymentRow>(
r#"
SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id
FROM payments
WHERE razorpay_order_id = $1 AND status = 'PENDING'
"#,
)
.bind(&payload.order_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(payment)) => payment,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Payment not found or already processed" })),
)
.into_response()
}
Err(e) => {
tracing::error!("Database error fetching payment: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
.into_response();
}
};
if payment.user_id != auth.user_id {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "Payment does not belong to user" })),
)
.into_response();
}
let package = match sqlx::query_as::<_, AiCreditPackageRow>(
"SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true",
)
.bind(payment.package_id.unwrap_or_default())
.fetch_optional(&state.pool)
.await
{
Ok(Some(package)) => package,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "AI credit package not found" })),
)
.into_response();
}
};
if let Err(e) = sqlx::query(
r#"
UPDATE payments SET
status = 'SUCCESS',
razorpay_payment_id = $1,
verified_at = NOW()
WHERE id = $2
"#,
)
.bind(&payload.payment_id)
.bind(payment.id)
.execute(&state.pool)
.await
{
tracing::error!("Failed to update payment status: {}", e);
}
let users_service_url =
std::env::var("USERS_SERVICE_URL").unwrap_or_else(|_| "http://nxtgauge-rust-users:9101".to_string());
let credit_url = format!(
"{}/api/admin/ai/users/{}/credits",
users_service_url.trim_end_matches('/'),
auth.user_id
);
let admin_token = match std::env::var("AI_CREDIT_ADMIN_TOKEN") {
Ok(token) => token,
Err(_) => {
tracing::error!("AI_CREDIT_ADMIN_TOKEN not set; cannot credit AI credits automatically");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "AI credit crediting is not configured" })),
)
.into_response();
}
};
let credit_resp = match state
.client
.post(&credit_url)
.header("Authorization", format!("Bearer {}", admin_token))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"credits": package.credits,
"source": "purchase",
"description": format!("AI credit package {}", package.id)
}))
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("Failed to call users service to credit AI credits: {}", e);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Failed to credit AI credits" })),
)
.into_response();
}
};
if !credit_resp.status().is_success() {
let err_body = credit_resp.text().await.unwrap_or_default();
tracing::error!("Users service rejected AI credit grant: {}", err_body);
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Failed to credit AI credits" })),
)
.into_response();
}
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(auth.user_id)
.bind("AI Credits Purchased")
.bind(format!("Your {} AI credits have been added to your account.", package.credits))
.bind("AI_CREDITS")
.bind(payment.id)
.execute(&state.pool)
.await;
(
StatusCode::OK,
Json(serde_json::json!({
"verified": true,
"credits_added": package.credits,
"payment_id": payload.payment_id,
"status": "success"
})),
)
.into_response()
}