nxtgauge-backend-rust/apps/payments/src/packages.rs
2026-06-09 22:52:30 +02:00

389 lines
12 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PackageTypeQuery {
pub package_type: Option<String>,
pub applicable_role: Option<String>,
pub role: Option<String>,
pub active_only: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub search: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePackageRequest {
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub role_key: Option<String>,
pub applicable_roles: Option<Vec<String>>,
pub tracecoins_amount: i32,
pub price: Option<i32>,
pub price_inr: Option<i32>,
pub is_active: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePackageRequest {
pub name: Option<String>,
pub description: Option<String>,
pub role_key: Option<String>,
pub applicable_roles: Option<Vec<String>>,
pub tracecoins_amount: Option<i32>,
pub price: Option<i32>,
pub price_inr: Option<i32>,
pub is_active: Option<bool>,
}
#[derive(Debug, FromRow)]
pub struct PricingPackageRow {
pub id: Uuid,
pub name: String,
pub role_key: String,
pub package_type: String,
pub tracecoins_amount: i32,
pub price_inr: i32,
pub description: Option<String>,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct PricingPackageResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub role_key: String,
pub applicable_roles: Vec<String>,
pub package_type: String,
pub tracecoins_amount: i32,
pub price: i32,
pub price_inr: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub is_available: bool,
pub is_expired: bool,
}
impl From<PricingPackageRow> for PricingPackageResponse {
fn from(row: PricingPackageRow) -> Self {
Self {
id: row.id,
name: row.name,
description: row.description,
role_key: row.role_key.clone(),
applicable_roles: vec![row.role_key],
package_type: row.package_type,
tracecoins_amount: row.tracecoins_amount,
price: row.price_inr,
price_inr: row.price_inr,
duration_days: None,
valid_from: None,
valid_until: None,
is_promotional: false,
is_active: row.is_active,
features: None,
created_at: row.created_at,
updated_at: row.created_at,
is_available: row.is_active,
is_expired: false,
}
}
}
fn normalize_role_key(role_key: Option<String>, applicable_roles: Option<Vec<String>>) -> Result<String, String> {
if let Some(role) = role_key {
let cleaned = role.trim().to_uppercase();
if !cleaned.is_empty() {
return Ok(cleaned);
}
}
if let Some(roles) = applicable_roles {
if let Some(role) = roles.into_iter().map(|role| role.trim().to_uppercase()).find(|role| !role.is_empty()) {
return Ok(role);
}
}
Err("role_key is required".to_string())
}
fn package_query(base_where: &str, order_by: &str) -> String {
format!(
r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
FROM pricing_packages
{base_where}
{order_by}
"#
)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_packages))
.route("/", post(create_package))
.route("/{id}", get(get_package))
.route("/{id}", patch(update_package))
.route("/{id}", delete(delete_package))
.route("/by-type", get(get_packages_by_type))
.route("/for-role", get(get_packages_for_role))
}
async fn list_packages(
State(state): State<AppState>,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let search = q.search.unwrap_or_default().trim().to_string();
let rows = sqlx::query_as::<_, PricingPackageRow>(
&format!(
"{} LIMIT $2 OFFSET $3",
package_query(
"WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
"ORDER BY created_at DESC"
)
),
)
.bind(&search)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let rows = match rows {
Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let total: i64 = match sqlx::query_scalar(
"SELECT COUNT(*) FROM pricing_packages WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
)
.bind(&search)
.fetch_one(&state.pool)
.await
{
Ok(total) => total,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"packages": packages,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"pages": (total as f64 / limit as f64).ceil() as i64
}
})))
.into_response()
}
async fn get_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(pkg)) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_package(
State(state): State<AppState>,
Json(payload): Json<CreatePackageRequest>,
) -> impl IntoResponse {
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
Ok(role_key) => role_key,
Err(message) => return (StatusCode::BAD_REQUEST, message).into_response(),
};
let price_inr = payload.price_inr.or(payload.price).unwrap_or(0);
let result = sqlx::query_as::<_, PricingPackageRow>(
r#"
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
"#,
)
.bind(&payload.name)
.bind(&role_key)
.bind(&payload.package_type)
.bind(payload.tracecoins_amount)
.bind(price_inr)
.bind(&payload.description)
.bind(payload.is_active.unwrap_or(true))
.fetch_one(&state.pool)
.await;
match result {
Ok(pkg) => (StatusCode::CREATED, Json(PricingPackageResponse::from(pkg))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePackageRequest>,
) -> impl IntoResponse {
let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(pkg)) => pkg,
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
Ok(role_key) => role_key,
Err(_) => current.role_key.clone(),
};
let price_inr = payload.price_inr.or(payload.price).unwrap_or(current.price_inr);
let updated = sqlx::query_as::<_, PricingPackageRow>(
r#"
UPDATE pricing_packages SET
name = COALESCE($2, name),
role_key = $3,
description = COALESCE($4, description),
tracecoins_amount = COALESCE($5, tracecoins_amount),
price_inr = $6,
is_active = COALESCE($7, is_active)
WHERE id = $1
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
"#,
)
.bind(id)
.bind(&payload.name)
.bind(&role_key)
.bind(&payload.description)
.bind(payload.tracecoins_amount)
.bind(price_inr)
.bind(payload.is_active)
.fetch_one(&state.pool)
.await;
match updated {
Ok(pkg) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn delete_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query("DELETE FROM pricing_packages WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
{
Ok(result) if result.rows_affected() > 0 => {
(StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response()
}
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_packages_by_type(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse {
let package_type = q.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
let rows = sqlx::query_as::<_, PricingPackageRow>(
&package_query(
"WHERE package_type = $1 AND is_active = true",
"ORDER BY price_inr ASC, created_at DESC",
),
)
.bind(&package_type)
.fetch_all(&state.pool)
.await;
let rows = match rows {
Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"package_type": package_type
})))
.into_response()
}
async fn get_packages_for_role(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse {
let role = q
.applicable_role
.or(q.role)
.unwrap_or_default()
.trim()
.to_uppercase();
let active_only = q.active_only.unwrap_or(true);
let rows = sqlx::query_as::<_, PricingPackageRow>(
&package_query(
"WHERE ($1 = '' OR role_key = $1) AND ($2 = false OR is_active = true)",
"ORDER BY price_inr ASC, created_at DESC",
),
)
.bind(&role)
.bind(active_only)
.fetch_all(&state.pool)
.await;
let rows = match rows {
Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"applicable_role": role
})))
.into_response()
}