Replace Razorpay payment integration with PayU hosted checkout

The frontend (lib/payu.ts, payu-return.tsx) and admin config UI had
already switched to PayU, but the payments service was still calling
Razorpay's order API and verifying Razorpay HMAC signatures -
payments were broken end to end. Rewrites apps/payments to PayU's
hash-based hosted-checkout flow (SHA-512 request/response hash,
key+salt from admin config or PAYU_MERCHANT_KEY/PAYU_SALT), for both
the tracecoin wallet purchase flow and the AI credits flow. The AI
credits frontend checkout was also fabricating a fake payment_id and
signature client-side instead of ever opening a real PayU checkout -
fixed to use the same real flow as tracecoin purchases.

Also fixes the ai_create_ticket endpoint on the users service, which
never validated the X-AI-Service-Key header despite the client
sending one - anyone could create tickets under an arbitrary user_id.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-02 18:31:07 +05:30
parent eb009ac3d0
commit 64089e4da3
4 changed files with 369 additions and 263 deletions

View file

@ -7,7 +7,6 @@ use axum::{
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use hmac::Mac;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
@ -19,9 +18,22 @@ struct CreateAiCreditOrderRequest {
#[derive(Debug, Deserialize, Serialize)]
struct VerifyAiCreditOrderRequest {
order_id: String,
payment_id: String,
signature: Option<String>,
txnid: String,
mihpayid: String,
status: String,
hash: String,
#[serde(default)]
amount: String,
#[serde(default)]
firstname: String,
#[serde(default)]
email: String,
#[serde(default)]
productinfo: String,
#[serde(default)]
udf1: String,
#[serde(default)]
udf2: String,
}
#[derive(Debug, Serialize, FromRow)]
@ -95,78 +107,53 @@ async fn create_ai_credit_order(
}
};
let gateway_config = match crate::resolve_razorpay_config(&state).await {
let gateway_config = match crate::resolve_payu_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 contact = sqlx::query_as::<_, crate::UserContactRow>(
"SELECT email, full_name, phone FROM users WHERE id = $1",
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await;
let (email, firstname, phone) = match contact {
Ok(Some(row)) => {
let firstname = row
.full_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.unwrap_or("Customer")
.to_string();
(row.email, firstname, row.phone.unwrap_or_default())
}
_ => (auth.email.clone(), "Customer".to_string(), String::new()),
};
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();
}
};
let txnid = crate::build_txnid();
let amount = format!("{:.2}", package.price_inr as f64 / 100.0);
let productinfo = package.name.clone();
let udf1 = package.id.to_string();
let udf2 = auth.user_id.to_string();
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();
let hash = crate::payu_request_hash(
&gateway_config.key,
&txnid,
&amount,
&productinfo,
&firstname,
&email,
&udf1,
&udf2,
"",
"",
"",
&gateway_config.salt,
);
if let Err(e) = sqlx::query(
r#"
@ -176,7 +163,7 @@ async fn create_ai_credit_order(
)
.bind(auth.user_id)
.bind(package.id)
.bind(&order_id)
.bind(&txnid)
.bind(package.price_inr)
.execute(&state.pool)
.await
@ -184,14 +171,31 @@ async fn create_ai_credit_order(
tracing::error!("Failed to record AI credit payment: {}", e);
}
let return_url = format!(
"{}/dashboard/wallet/payu-return",
state.frontend_url.trim_end_matches('/')
);
(
StatusCode::OK,
Json(serde_json::json!({
"order_id": order_id,
"amount": package.price_inr,
"key": gateway_config.key,
"txnid": txnid,
"order_id": txnid,
"amount": amount,
"productinfo": productinfo,
"firstname": firstname,
"email": email,
"phone": phone,
"surl": return_url,
"furl": return_url,
"hash": hash,
"payu_base_url": gateway_config.base_url,
"udf1": udf1,
"udf2": udf2,
"currency": "INR",
"credits": package.credits,
"status": body.get("status").and_then(|v| v.as_str()).unwrap_or("created"),
"status": "created",
})),
)
.into_response()
@ -202,47 +206,30 @@ async fn verify_ai_credit_order(
State(state): State<AppState>,
Json(payload): Json<VerifyAiCreditOrderRequest>,
) -> impl IntoResponse {
let gateway_config = match crate::resolve_razorpay_config(&state).await {
let gateway_config = match crate::resolve_payu_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 expected_hash = crate::payu_response_hash(
&gateway_config.salt,
&payload.status,
&payload.udf1,
&payload.udf2,
"",
"",
"",
&payload.email,
&payload.firstname,
&payload.productinfo,
&payload.amount,
&payload.txnid,
&gateway_config.key,
);
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() {
if !expected_hash.eq_ignore_ascii_case(&payload.hash) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Signature mismatch" })),
@ -250,6 +237,14 @@ async fn verify_ai_credit_order(
.into_response();
}
if !payload.status.eq_ignore_ascii_case("success") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "PayU reported a non-success status." })),
)
.into_response();
}
let payment = match sqlx::query_as::<_, crate::PaymentRow>(
r#"
SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
@ -257,7 +252,7 @@ async fn verify_ai_credit_order(
WHERE payu_txnid = $1 AND status = 'PENDING'
"#,
)
.bind(&payload.order_id)
.bind(&payload.txnid)
.fetch_optional(&state.pool)
.await
{
@ -313,7 +308,7 @@ async fn verify_ai_credit_order(
WHERE id = $2
"#,
)
.bind(&payload.payment_id)
.bind(&payload.mihpayid)
.bind(payment.id)
.execute(&state.pool)
.await
@ -393,7 +388,7 @@ async fn verify_ai_credit_order(
Json(serde_json::json!({
"verified": true,
"credits_added": package.credits,
"payment_id": payload.payment_id,
"payment_id": payload.mihpayid,
"status": "success"
})),
)

View file

@ -5,9 +5,8 @@ use axum::{
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sha2::{Digest, Sha512};
use sqlx::{postgres::PgPool, FromRow};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -16,40 +15,65 @@ use uuid::Uuid;
pub mod ai_credits;
pub mod packages;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct AppState {
client: reqwest::Client,
pool: PgPool,
fallback_razorpay_key_id: String,
fallback_razorpay_key_secret: String,
pub(crate) client: reqwest::Client,
fallback_payu_key: String,
fallback_payu_salt: String,
frontend_url: String,
}
#[derive(Debug, Deserialize)]
struct CreateOrderRequest {
amount: u64,
#[allow(dead_code)]
currency: Option<String>,
#[allow(dead_code)]
receipt: Option<String>,
package_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct CreateOrderResponse {
order_id: String,
amount: u64,
currency: String,
status: String,
struct PayuOrderResponse {
key: String,
txnid: String,
amount: String,
productinfo: String,
firstname: String,
email: String,
phone: String,
surl: String,
furl: String,
hash: String,
payu_base_url: String,
udf1: String,
udf2: String,
}
#[derive(Debug, Deserialize)]
struct VerifyPaymentRequest {
#[serde(alias = "razorpay_order_id")]
order_id: Option<String>,
#[serde(alias = "razorpay_payment_id")]
payment_id: Option<String>,
#[serde(alias = "razorpay_signature")]
signature: Option<String>,
status: String,
txnid: String,
amount: String,
mihpayid: String,
hash: String,
#[serde(default)]
firstname: String,
#[serde(default)]
email: String,
#[serde(default)]
productinfo: String,
#[serde(default)]
udf1: String,
#[serde(default)]
udf2: String,
#[serde(default)]
udf3: String,
#[serde(default)]
udf4: String,
#[serde(default)]
udf5: String,
}
#[derive(Debug, Serialize)]
@ -70,14 +94,23 @@ struct PaymentStatusResponse {
#[derive(Debug, FromRow)]
struct PricingPackageRow {
name: String,
tracecoins_amount: i32,
price_inr: i32,
}
#[derive(Debug, FromRow)]
pub(crate) struct UserContactRow {
pub(crate) email: String,
pub(crate) full_name: Option<String>,
pub(crate) phone: Option<String>,
}
#[derive(Debug, FromRow)]
struct PaymentRow {
id: Uuid,
user_id: Uuid,
#[allow(dead_code)]
package_id: Option<Uuid>,
tracecoins_credited: Option<i32>,
amount_inr: i32,
@ -85,28 +118,10 @@ struct PaymentRow {
payu_mihpayid: Option<String>,
}
#[derive(Debug, Serialize)]
struct RazorpayOrderRequest {
amount: u64,
currency: String,
receipt: String,
notes: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct RazorpayOrderResponse {
id: String,
amount: u64,
currency: String,
status: String,
}
#[derive(Debug, Deserialize)]
struct StoredGatewayConfig {
mode: Option<String>,
base_url: Option<String>,
callback_url: Option<String>,
webhook_url: Option<String>,
merchant_id: Option<String>,
api_key: Option<String>,
secret_key: Option<String>,
@ -114,37 +129,38 @@ struct StoredGatewayConfig {
#[derive(Debug, FromRow)]
struct PaymentGatewayConfigRow {
#[allow(dead_code)]
display_name: Option<String>,
config_json: Option<serde_json::Value>,
is_active: bool,
}
#[derive(Debug, Clone)]
struct RazorpayConfig {
key_id: String,
key_secret: String,
base_url: String,
mode: String,
enabled: bool,
pub(crate) struct PayuConfig {
pub(crate) key: String,
pub(crate) salt: String,
pub(crate) base_url: String,
}
fn error_response(status: StatusCode, message: impl Into<String>) -> (StatusCode, String) {
(status, message.into())
}
fn build_receipt(receipt: Option<String>) -> String {
let candidate = receipt.unwrap_or_else(|| format!("tc_{}", Uuid::new_v4().simple()));
candidate.chars().take(40).collect()
pub(crate) fn build_txnid() -> String {
format!("tc{}", Uuid::new_v4().simple())
.chars()
.take(40)
.collect()
}
pub(crate) async fn resolve_razorpay_config(
state: &AppState,
) -> Result<RazorpayConfig, (StatusCode, String)> {
/// Resolves the PayU merchant key/salt from the admin-configured gateway settings,
/// falling back to the deployment's PAYU_MERCHANT_KEY / PAYU_SALT env vars.
pub(crate) async fn resolve_payu_config(state: &AppState) -> Result<PayuConfig, (StatusCode, String)> {
let row = sqlx::query_as::<_, PaymentGatewayConfigRow>(
r#"
SELECT display_name, config_json, is_active
FROM payment_gateway_configs
WHERE gateway_key = 'RAZORPAY'
WHERE gateway_key = 'PAYU'
ORDER BY is_active DESC, created_at DESC
LIMIT 1
"#,
@ -153,9 +169,9 @@ pub(crate) async fn resolve_razorpay_config(
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let mut key_id = state.fallback_razorpay_key_id.clone();
let mut key_secret = state.fallback_razorpay_key_secret.clone();
let mut base_url = "https://api.razorpay.com".to_string();
let mut key = state.fallback_payu_key.clone();
let mut salt = state.fallback_payu_salt.clone();
let mut base_url = "https://test.payu.in".to_string();
let mut mode = "sandbox".to_string();
let mut enabled = true;
@ -163,51 +179,104 @@ pub(crate) async fn resolve_razorpay_config(
enabled = row.is_active;
if let Some(config_json) = row.config_json {
if let Ok(stored) = serde_json::from_value::<StoredGatewayConfig>(config_json) {
if let Some(value) = stored.api_key.filter(|v| !v.trim().is_empty()) {
key_id = value;
// merchant_id / api_key hold the PayU merchant key; secret_key holds the PayU salt.
if let Some(value) = stored
.api_key
.filter(|v| !v.trim().is_empty())
.or_else(|| stored.merchant_id.filter(|v| !v.trim().is_empty()))
{
key = value;
}
if let Some(value) = stored.secret_key.filter(|v| !v.trim().is_empty()) {
key_secret = value;
}
if let Some(value) = stored.base_url.filter(|v| !v.trim().is_empty()) {
base_url = value;
salt = value;
}
if let Some(value) = stored.mode.filter(|v| !v.trim().is_empty()) {
mode = value;
}
if let Some(value) = stored.base_url.filter(|v| !v.trim().is_empty()) {
base_url = value;
}
}
}
}
if mode.eq_ignore_ascii_case("live") && base_url == "https://test.payu.in" {
base_url = "https://secure.payu.in".to_string();
}
if !enabled {
return Err(error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Razorpay gateway is disabled",
"PayU gateway is disabled",
));
}
if key_id.trim().is_empty() || key_secret.trim().is_empty() {
if key.trim().is_empty() || salt.trim().is_empty() {
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Razorpay credentials are not configured",
"PayU credentials are not configured",
));
}
Ok(RazorpayConfig {
key_id,
key_secret,
base_url,
mode,
enabled,
})
Ok(PayuConfig { key, salt, base_url })
}
fn sha512_hex(raw: &str) -> String {
let digest = Sha512::digest(raw.as_bytes());
hex::encode(digest)
}
/// PayU's documented request hash: sha512(key|txnid|amount|productinfo|firstname|email|udf1..udf5|||||SALT)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_request_hash(
key: &str,
txnid: &str,
amount: &str,
productinfo: &str,
firstname: &str,
email: &str,
udf1: &str,
udf2: &str,
udf3: &str,
udf4: &str,
udf5: &str,
salt: &str,
) -> String {
let raw = format!(
"{key}|{txnid}|{amount}|{productinfo}|{firstname}|{email}|{udf1}|{udf2}|{udf3}|{udf4}|{udf5}||||||{salt}"
);
sha512_hex(&raw)
}
/// PayU's documented response hash: sha512(SALT|status|||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_response_hash(
salt: &str,
status: &str,
udf1: &str,
udf2: &str,
udf3: &str,
udf4: &str,
udf5: &str,
email: &str,
firstname: &str,
productinfo: &str,
amount: &str,
txnid: &str,
key: &str,
) -> String {
let raw = format!(
"{salt}|{status}||||||{udf5}|{udf4}|{udf3}|{udf2}|{udf1}|{email}|{firstname}|{productinfo}|{amount}|{txnid}|{key}"
);
sha512_hex(&raw)
}
async fn create_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateOrderRequest>,
) -> Result<Json<CreateOrderResponse>, (StatusCode, String)> {
let gateway_config = resolve_razorpay_config(&state).await?;
) -> Result<Json<PayuOrderResponse>, (StatusCode, String)> {
let gateway_config = resolve_payu_config(&state).await?;
let package_id_str = payload
.package_id
.as_ref()
@ -222,9 +291,8 @@ async fn create_order(
));
}
let currency = payload.currency.unwrap_or_else(|| "INR".to_string());
let package = sqlx::query_as::<_, PricingPackageRow>(
"SELECT tracecoins_amount, price_inr FROM pricing_packages WHERE id = $1 AND is_active = true",
"SELECT name, tracecoins_amount, price_inr FROM pricing_packages WHERE id = $1 AND is_active = true",
)
.bind(package_id)
.fetch_optional(&state.pool)
@ -242,48 +310,47 @@ async fn create_order(
));
}
let razorpay_request = RazorpayOrderRequest {
amount: payload.amount,
currency: currency.clone(),
receipt: build_receipt(payload.receipt),
notes: serde_json::json!({
"package_id": package_id_str,
"user_id": auth.user_id.to_string(),
}),
let contact = sqlx::query_as::<_, UserContactRow>(
"SELECT email, full_name, phone FROM users WHERE id = $1",
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let (email, firstname, phone) = match contact {
Some(row) => {
let firstname = row
.full_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.unwrap_or("Customer")
.to_string();
(row.email, firstname, row.phone.unwrap_or_default())
}
None => (auth.email.clone(), "Customer".to_string(), String::new()),
};
let resp = 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(&razorpay_request)
.send()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("Razorpay request failed: {e}")))?;
let txnid = build_txnid();
let amount = format!("{:.2}", payload.amount as f64 / 100.0);
let productinfo = package.name.clone();
let udf1 = package_id_str.clone();
let udf2 = auth.user_id.to_string();
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
return Err(error_response(
StatusCode::UNAUTHORIZED,
"Razorpay authentication failed",
));
}
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|_| "Unable to read Razorpay error response".to_string());
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Razorpay order creation failed: {body}"),
));
}
let razorpay_order = resp
.json::<RazorpayOrderResponse>()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid Razorpay response: {e}")))?;
let hash = payu_request_hash(
&gateway_config.key,
&txnid,
&amount,
&productinfo,
&firstname,
&email,
&udf1,
&udf2,
"",
"",
"",
&gateway_config.salt,
);
sqlx::query(
r#"
@ -293,18 +360,29 @@ async fn create_order(
)
.bind(auth.user_id)
.bind(package_id)
.bind(&razorpay_order.id)
.bind(razorpay_order.amount as i64)
.bind(&txnid)
.bind(payload.amount as i32)
.bind(package.tracecoins_amount)
.execute(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(CreateOrderResponse {
order_id: razorpay_order.id,
amount: razorpay_order.amount,
currency: razorpay_order.currency,
status: razorpay_order.status,
let return_url = format!("{}/dashboard/wallet/payu-return", state.frontend_url.trim_end_matches('/'));
Ok(Json(PayuOrderResponse {
key: gateway_config.key,
txnid,
amount,
productinfo,
firstname,
email,
phone,
surl: return_url.clone(),
furl: return_url,
hash,
payu_base_url: gateway_config.base_url,
udf1,
udf2,
}))
}
@ -313,25 +391,27 @@ async fn verify_payment(
State(state): State<AppState>,
Json(payload): Json<VerifyPaymentRequest>,
) -> Result<Json<VerifyPaymentResponse>, (StatusCode, String)> {
let gateway_config = resolve_razorpay_config(&state).await?;
let order_id = payload
.order_id
.ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "order_id is required"))?;
let payment_id = payload
.payment_id
.ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "payment_id is required"))?;
let signature = payload
.signature
.ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "razorpay_signature is required"))?;
let gateway_config = resolve_payu_config(&state).await?;
let provided_signature = hex::decode(signature)
.map_err(|_| error_response(StatusCode::BAD_REQUEST, "Invalid signature format"))?;
let expected_hash = payu_response_hash(
&gateway_config.salt,
&payload.status,
&payload.udf1,
&payload.udf2,
&payload.udf3,
&payload.udf4,
&payload.udf5,
&payload.email,
&payload.firstname,
&payload.productinfo,
&payload.amount,
&payload.txnid,
&gateway_config.key,
);
let mut mac = HmacSha256::new_from_slice(gateway_config.key_secret.as_bytes())
.map_err(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "Unable to initialize signature verifier"))?;
mac.update(format!("{order_id}|{payment_id}").as_bytes());
mac.verify_slice(&provided_signature)
.map_err(|_| error_response(StatusCode::BAD_REQUEST, "Signature mismatch"))?;
if !expected_hash.eq_ignore_ascii_case(&payload.hash) {
return Err(error_response(StatusCode::BAD_REQUEST, "Signature mismatch"));
}
let payment = sqlx::query_as::<_, PaymentRow>(
r#"
@ -340,7 +420,7 @@ async fn verify_payment(
WHERE payu_txnid = $1 AND status = 'PENDING'
"#,
)
.bind(&order_id)
.bind(&payload.txnid)
.fetch_optional(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -362,6 +442,22 @@ async fn verify_payment(
));
}
if !payload.status.eq_ignore_ascii_case("success") {
sqlx::query("UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2")
.bind(&payload.mihpayid)
.bind(payment.id)
.execute(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
return Ok(Json(VerifyPaymentResponse {
verified: false,
payment_id: payload.mihpayid,
status: "failed".to_string(),
message: "PayU reported a non-success status.".to_string(),
}));
}
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
sqlx::query(
@ -373,7 +469,7 @@ async fn verify_payment(
WHERE id = $2
"#,
)
.bind(&payment_id)
.bind(&payload.mihpayid)
.bind(payment.id)
.execute(&state.pool)
.await
@ -432,7 +528,7 @@ async fn verify_payment(
Ok(Json(VerifyPaymentResponse {
verified: true,
payment_id,
payment_id: payload.mihpayid,
status: "success".to_string(),
message: "Payment verified successfully".to_string(),
}))
@ -501,12 +597,12 @@ async fn main() {
.expect("Failed to connect to database");
let state = AppState {
client: reqwest::Client::new(),
pool,
fallback_razorpay_key_id: std::env::var("RAZORPAY_KEY_ID")
.expect("RAZORPAY_KEY_ID must be set"),
fallback_razorpay_key_secret: std::env::var("RAZORPAY_KEY_SECRET")
.expect("RAZORPAY_KEY_SECRET must be set"),
client: reqwest::Client::new(),
fallback_payu_key: std::env::var("PAYU_MERCHANT_KEY").unwrap_or_default(),
fallback_payu_salt: std::env::var("PAYU_SALT").unwrap_or_default(),
frontend_url: std::env::var("FRONTEND_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
};
let app = Router::new()

View file

@ -9,7 +9,7 @@ use axum::{
use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize};
const RAZORPAY_GATEWAY_KEY: &str = "RAZORPAY";
const PAYU_GATEWAY_KEY: &str = "PAYU";
pub fn router() -> Router<AppState> {
Router::new()
@ -42,10 +42,10 @@ struct PaymentGatewayRow {
impl Default for PaymentGatewayConfigPayload {
fn default() -> Self {
Self {
provider: "Razorpay".to_string(),
provider: "PayU".to_string(),
mode: "sandbox".to_string(),
enabled: true,
base_url: "https://api.razorpay.com".to_string(),
base_url: "https://test.payu.in".to_string(),
callback_url: "".to_string(),
webhook_url: "".to_string(),
merchant_id: "".to_string(),
@ -58,7 +58,7 @@ impl Default for PaymentGatewayConfigPayload {
fn normalize_payload(payload: PaymentGatewayConfigPayload) -> PaymentGatewayConfigPayload {
PaymentGatewayConfigPayload {
provider: if payload.provider.trim().is_empty() {
"Razorpay".to_string()
"PayU".to_string()
} else {
payload.provider.trim().to_string()
},
@ -69,7 +69,7 @@ fn normalize_payload(payload: PaymentGatewayConfigPayload) -> PaymentGatewayConf
},
enabled: payload.enabled,
base_url: if payload.base_url.trim().is_empty() {
"https://api.razorpay.com".to_string()
"https://test.payu.in".to_string()
} else {
payload.base_url.trim().to_string()
},
@ -86,7 +86,7 @@ fn row_to_payload(row: PaymentGatewayRow) -> PaymentGatewayConfigPayload {
payload.provider = row
.display_name
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| "Razorpay".to_string());
.unwrap_or_else(|| "PayU".to_string());
payload.enabled = row.is_active;
if let Some(config) = row.config_json {
@ -98,7 +98,7 @@ fn row_to_payload(row: PaymentGatewayRow) -> PaymentGatewayConfigPayload {
payload.base_url = config
.get("base_url")
.and_then(|v| v.as_str())
.unwrap_or("https://api.razorpay.com")
.unwrap_or("https://test.payu.in")
.to_string();
payload.callback_url = config
.get("callback_url")
@ -145,7 +145,7 @@ async fn get_payment_gateway_config(
LIMIT 1
"#,
)
.bind(RAZORPAY_GATEWAY_KEY)
.bind(PAYU_GATEWAY_KEY)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -154,8 +154,8 @@ async fn get_payment_gateway_config(
Some(row) => row_to_payload(row),
None => {
let mut defaults = PaymentGatewayConfigPayload::default();
defaults.api_key = std::env::var("RAZORPAY_KEY_ID").unwrap_or_default();
defaults.secret_key = std::env::var("RAZORPAY_KEY_SECRET").unwrap_or_default();
defaults.api_key = std::env::var("PAYU_MERCHANT_KEY").unwrap_or_default();
defaults.secret_key = std::env::var("PAYU_SALT").unwrap_or_default();
defaults
}
};
@ -191,7 +191,7 @@ async fn upsert_payment_gateway_config(
LIMIT 1
"#,
)
.bind(RAZORPAY_GATEWAY_KEY)
.bind(PAYU_GATEWAY_KEY)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -222,7 +222,7 @@ async fn upsert_payment_gateway_config(
RETURNING display_name, config_json, is_active
"#,
)
.bind(RAZORPAY_GATEWAY_KEY)
.bind(PAYU_GATEWAY_KEY)
.bind(&payload.provider)
.bind(config_json)
.bind(payload.enabled)

View file

@ -1,7 +1,7 @@
use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
@ -107,8 +107,23 @@ struct AiCreateTicketBody {
async fn ai_create_ticket(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Json(body): axum::extract::Json<AiCreateTicketBody>,
) -> impl IntoResponse {
let expected_key = std::env::var("AI_SERVICE_KEY").unwrap_or_default();
let provided_key = headers
.get("X-AI-Service-Key")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if expected_key.is_empty() || provided_key != expected_key {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "Invalid or missing AI service key" })),
)
.into_response();
}
let user_id = body.user_id.unwrap_or_else(|| Uuid::nil());
let category = body.category.clone().unwrap_or_else(|| "ai_assisted".to_string());
let priority = body.priority.clone().unwrap_or_else(|| "medium".to_string());