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:
parent
eb009ac3d0
commit
64089e4da3
4 changed files with 369 additions and 263 deletions
|
|
@ -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"
|
||||
})),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue