401 lines
12 KiB
Rust
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()
|
|
}
|