use crate::AppState; use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, patch, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use uuid::Uuid; // ── Routers ─────────────────────────────────────────────────────────────────── pub fn coupons_router() -> Router { Router::new() .route("/", get(list_coupons).post(create_coupon)) .route("/{id}", patch(update_coupon).delete(delete_coupon)) .route("/validate", post(validate_coupon)) } pub fn discounts_router() -> Router { Router::new() .route("/", get(list_discounts).post(create_discount)) .route("/{id}", patch(update_discount)) } // ── Coupon DTOs ─────────────────────────────────────────────────────────────── #[derive(Serialize)] struct CouponDto { id: Uuid, code: String, title: Option, #[serde(rename = "type")] discount_type: String, value: i32, min_order_amount: i32, usage_limit: Option, used_count: i32, role_keys: Vec, applies_to: String, is_active: bool, valid_until: Option>, } #[derive(Deserialize)] struct CreateCouponBody { code: String, title: Option, #[serde(rename = "type")] discount_type: Option, value: Option, min_order_amount: Option, max_uses: Option, role_keys: Option>, applies_to: Option, } #[derive(Deserialize)] struct PatchCouponBody { code: Option, title: Option, #[serde(rename = "type")] discount_type: Option, value: Option, min_order_amount: Option, max_uses: Option, role_keys: Option>, is_active: Option, } // ── Discount DTOs ───────────────────────────────────────────────────────────── #[derive(Serialize)] struct DiscountDto { id: Uuid, title: String, scope: String, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: String, value: i32, is_active: bool, } #[derive(Deserialize)] struct CreateDiscountBody { title: String, scope: Option, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: Option, value: Option, } #[derive(Deserialize)] struct PatchDiscountBody { title: Option, scope: Option, role_key: Option, package_id: Option, #[serde(rename = "type")] discount_type: Option, value: Option, is_active: Option, } // ── FromRow structs ────────────────────────────────────────────────────────── #[derive(sqlx::FromRow)] struct CouponRow { id: Uuid, code: String, title: Option, discount_type: String, discount_value: i32, min_order_amount: i32, max_uses: Option, uses_count: i32, role_keys: Vec, applies_to: String, is_active: bool, valid_until: Option>, } #[derive(sqlx::FromRow)] struct ExistingCouponRow { code: String, title: Option, discount_type: String, discount_value: i32, min_order_amount: i32, max_uses: Option, role_keys: Vec, is_active: bool, } #[derive(sqlx::FromRow)] struct ValidateCouponRow { id: Uuid, code: String, discount_type: String, discount_value: i32, min_order_amount: i32, max_uses: Option, role_keys: Vec, valid_until: Option>, is_active: bool, } #[derive(sqlx::FromRow)] struct DiscountRow { id: Uuid, title: String, scope: String, role_key: Option, package_id: Option, discount_type: String, discount_value: i32, is_active: bool, } #[derive(sqlx::FromRow)] struct ExistingDiscountRow { title: String, scope: String, role_key: Option, package_id: Option, discount_type: String, discount_value: i32, is_active: bool, } // ── Coupon handlers ─────────────────────────────────────────────────────────── async fn list_coupons( _auth: AuthUser, State(state): State, ) -> impl IntoResponse { let rows = sqlx::query_as::<_, CouponRow>( r#" SELECT id, code, title, discount_type, discount_value, min_order_amount, max_uses, uses_count, role_keys, applies_to, is_active, valid_until FROM coupons ORDER BY created_at DESC "#, ) .fetch_all(&state.pool) .await; match rows { Ok(rows) => { let dtos: Vec = rows .into_iter() .map(|r| CouponDto { id: r.id, code: r.code, title: r.title, discount_type: r.discount_type, value: r.discount_value, min_order_amount: r.min_order_amount, usage_limit: r.max_uses, used_count: r.uses_count, role_keys: r.role_keys, applies_to: r.applies_to, is_active: r.is_active, valid_until: r.valid_until, }) .collect(); (StatusCode::OK, Json(serde_json::json!({ "coupons": dtos }))).into_response() } Err(e) => { tracing::error!("Failed to list coupons: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load coupons" }))).into_response() } } } async fn create_coupon( _auth: AuthUser, State(state): State, Json(body): Json, ) -> impl IntoResponse { let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string()); let value = body.value.unwrap_or(0); let min_order = body.min_order_amount.unwrap_or(0); let applies_to = body.applies_to.unwrap_or_else(|| "ALL".to_string()); let role_keys: Vec = body.role_keys.unwrap_or_default(); let code = body.code.to_uppercase(); let row = sqlx::query_as::<_, CouponRow>( r#" INSERT INTO coupons (code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, applies_to) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, code, title, discount_type, discount_value, min_order_amount, max_uses, uses_count, role_keys, applies_to, is_active, valid_until "#, ) .bind(&code) .bind(&body.title) .bind(&discount_type) .bind(value) .bind(min_order) .bind(body.max_uses) .bind(&role_keys) .bind(&applies_to) .fetch_one(&state.pool) .await; match row { Ok(r) => { let dto = CouponDto { id: r.id, code: r.code, title: r.title, discount_type: r.discount_type, value: r.discount_value, min_order_amount: r.min_order_amount, usage_limit: r.max_uses, used_count: r.uses_count, role_keys: r.role_keys, applies_to: r.applies_to, is_active: r.is_active, valid_until: r.valid_until, }; (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() } Err(e) => { tracing::error!("Failed to create coupon: {e}"); if e.to_string().contains("unique") { (StatusCode::CONFLICT, Json(serde_json::json!({ "error": "Coupon code already exists" }))).into_response() } else { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create coupon" }))).into_response() } } } } async fn update_coupon( _auth: AuthUser, State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let existing = sqlx::query_as::<_, ExistingCouponRow>( "SELECT code, title, discount_type, discount_value, min_order_amount, max_uses, role_keys, is_active FROM coupons WHERE id = $1", ) .bind(id) .fetch_optional(&state.pool) .await; let existing = match existing { Ok(Some(r)) => r, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Coupon not found" }))).into_response(), Err(e) => { tracing::error!("DB error: {e}"); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "DB error" }))).into_response(); } }; let code = body.code.map(|c| c.to_uppercase()).unwrap_or(existing.code); let title = body.title.or(existing.title); let discount_type = body.discount_type.unwrap_or(existing.discount_type); let value = body.value.unwrap_or(existing.discount_value); let min_order = body.min_order_amount.unwrap_or(existing.min_order_amount); let max_uses = body.max_uses.or(existing.max_uses); let role_keys = body.role_keys.unwrap_or(existing.role_keys); let is_active = body.is_active.unwrap_or(existing.is_active); let result = sqlx::query( r#" UPDATE coupons SET code = $1, title = $2, discount_type = $3, discount_value = $4, min_order_amount = $5, max_uses = $6, role_keys = $7, is_active = $8 WHERE id = $9 "#, ) .bind(code) .bind(title) .bind(discount_type) .bind(value) .bind(min_order) .bind(max_uses) .bind(&role_keys) .bind(is_active) .bind(id) .execute(&state.pool) .await; match result { Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Err(e) => { tracing::error!("Failed to update coupon {id}: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update coupon" }))).into_response() } } } async fn delete_coupon( _auth: AuthUser, State(state): State, Path(id): Path, ) -> impl IntoResponse { let result = sqlx::query("DELETE FROM coupons WHERE id = $1") .bind(id) .execute(&state.pool) .await; match result { Ok(r) if r.rows_affected() == 0 => { (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Coupon not found" }))).into_response() } Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { tracing::error!("Failed to delete coupon {id}: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to delete coupon" }))).into_response() } } } // ── Discount handlers ───────────────────────────────────────────────────────── async fn list_discounts( _auth: AuthUser, State(state): State, ) -> impl IntoResponse { let rows = sqlx::query_as::<_, DiscountRow>( r#" SELECT id, title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts ORDER BY created_at DESC "#, ) .fetch_all(&state.pool) .await; match rows { Ok(rows) => { let dtos: Vec = rows .into_iter() .map(|r| DiscountDto { id: r.id, title: r.title, scope: r.scope, role_key: r.role_key, package_id: r.package_id, discount_type: r.discount_type, value: r.discount_value, is_active: r.is_active, }) .collect(); (StatusCode::OK, Json(serde_json::json!({ "discounts": dtos }))).into_response() } Err(e) => { tracing::error!("Failed to list discounts: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load discounts" }))).into_response() } } } async fn create_discount( _auth: AuthUser, State(state): State, Json(body): Json, ) -> impl IntoResponse { let scope = body.scope.unwrap_or_else(|| "ROLE".to_string()); let discount_type = body.discount_type.unwrap_or_else(|| "PERCENT".to_string()); let value = body.value.unwrap_or(0); let row = sqlx::query_as::<_, DiscountRow>( r#" INSERT INTO discounts (title, scope, role_key, package_id, discount_type, discount_value) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, title, scope, role_key, package_id, discount_type, discount_value, is_active "#, ) .bind(&body.title) .bind(&scope) .bind(&body.role_key) .bind(body.package_id) .bind(&discount_type) .bind(value) .fetch_one(&state.pool) .await; match row { Ok(r) => { let dto = DiscountDto { id: r.id, title: r.title, scope: r.scope, role_key: r.role_key, package_id: r.package_id, discount_type: r.discount_type, value: r.discount_value, is_active: r.is_active, }; (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() } Err(e) => { tracing::error!("Failed to create discount: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create discount" }))).into_response() } } } async fn update_discount( _auth: AuthUser, State(state): State, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let existing = sqlx::query_as::<_, ExistingDiscountRow>( "SELECT title, scope, role_key, package_id, discount_type, discount_value, is_active FROM discounts WHERE id = $1", ) .bind(id) .fetch_optional(&state.pool) .await; let existing = match existing { Ok(Some(r)) => r, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Discount not found" }))).into_response(), Err(e) => { tracing::error!("DB error: {e}"); return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "DB error" }))).into_response(); } }; let title = body.title.unwrap_or(existing.title); let scope = body.scope.unwrap_or(existing.scope); let role_key = body.role_key.or(existing.role_key); let package_id = body.package_id.or(existing.package_id); let discount_type = body.discount_type.unwrap_or(existing.discount_type); let value = body.value.unwrap_or(existing.discount_value); let is_active = body.is_active.unwrap_or(existing.is_active); let result = sqlx::query( r#" UPDATE discounts SET title = $1, scope = $2, role_key = $3, package_id = $4, discount_type = $5, discount_value = $6, is_active = $7 WHERE id = $8 "#, ) .bind(title) .bind(scope) .bind(role_key) .bind(package_id) .bind(discount_type) .bind(value) .bind(is_active) .bind(id) .execute(&state.pool) .await; match result { Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "updated": true }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), } } #[derive(Deserialize)] struct ValidateCouponPayload { coupon_code: String, role_key: String, package_price_inr: i32, } #[derive(Serialize)] struct ValidateCouponResponse { valid: bool, discount_type: Option, discount_value: Option, final_price_inr: i32, message: String, } async fn validate_coupon( _auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result { let code = payload.coupon_code.trim().to_uppercase(); // Fetch coupon let coupon = sqlx::query_as::<_, ValidateCouponRow>( r#" SELECT id, code, discount_type, discount_value, min_order_amount, max_uses, role_keys, valid_until, is_active FROM coupons WHERE code = $1 "#, ) .bind(&code) .fetch_optional(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; let coupon = match coupon { Some(c) => c, None => { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: "Coupon not found".to_string(), }), )); } }; // Check active if !coupon.is_active { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: "Coupon is inactive".to_string(), }), )); } // Check expiry if let Some(valid_until) = coupon.valid_until { if valid_until < chrono::Utc::now() { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: "Coupon has expired".to_string(), }), )); } } // Check role restriction if !coupon.role_keys.is_empty() && !coupon.role_keys.iter().any(|r| r.eq_ignore_ascii_case(&payload.role_key)) { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: "Coupon not valid for your role".to_string(), }), )); } // Check minimum order amount if payload.package_price_inr < coupon.min_order_amount { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: format!("Minimum order amount ₹{} required", coupon.min_order_amount / 100), }), )); } // Check usage limit if set if let Some(max_uses) = coupon.max_uses { let count: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1", ) .bind(coupon.id) .fetch_one(&state.pool) .await .unwrap_or(0); if count >= max_uses as i64 { return Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: false, discount_type: None, discount_value: None, final_price_inr: payload.package_price_inr, message: "Coupon usage limit reached".to_string(), }), )); } } // Compute final price let final_price = match coupon.discount_type.as_str() { "PERCENT" => { let discount = ((payload.package_price_inr as f64) * (coupon.discount_value as f64) / 100.0).round() as i32; (payload.package_price_inr - discount).max(0) } "FIXED" => (payload.package_price_inr - coupon.discount_value).max(0), _ => payload.package_price_inr, }; Ok(( StatusCode::OK, Json(ValidateCouponResponse { valid: true, discount_type: Some(coupon.discount_type), discount_value: Some(coupon.discount_value), final_price_inr: final_price, message: "Coupon applied".to_string(), }), )) }