nxtgauge-backend-rust/apps/payments/src/payu.rs
Ashwin Kumar Sivakumar 0dd5045676 feat: Complete Ask Ash AI Credits implementation on high-performance branch (Tasks 1-10)
- Task 1: Admin endpoints for wallet management
- Task 2: AI Credits admin UI (pricing.tsx, credit.tsx)
- Task 3: Ollama security (NetworkPolicy, prompt validation, audit)
- Task 4: LiteLLM integration (litellm.rs, migrated AI feature handlers)
- Task 5: Refund architecture (ai_refunds table, endpoints)
- Task 6: Coupons, promotions, referrals (order creation with coupon)
- Task 7: Subscription lifecycle (plan upgrades/downgrades, cron jobs)
- Task 8: Credit expiration enforcement (daily cron task)
- Task 9: Token cost engine (ai_model_cost_config, margin view)
- Task 10: Observability (metrics tables, aggregation function)

Cherry-picked from main branch commit 3c0f45f
2026-07-06 01:49:16 +05:30

188 lines
5.3 KiB
Rust

//! Real PayU integration, replacing the mocked Beeceptor calls in
//! `create_order`/`verify_payment`. Matches the checkout contract the
//! frontend (`nxtgauge-frontend-solid/src/lib/payu.ts`) already expects:
//! a form-redirect to `{payu_base_url}/_payment` with a request hash, and
//! a reverse-hash-verified response on callback.
//!
//! Hash formulas are PayU's standard v1 scheme (documented publicly by
//! PayU): request hash covers key/txnid/amount/productinfo/firstname/
//! email/udf1-10/salt; response verification reverses that order and
//! substitutes `status` for the trailing empty slots.
use sha2::{Digest, Sha512};
#[derive(Clone)]
pub struct PayuConfig {
pub merchant_key: String,
pub merchant_salt: String,
pub base_url: String,
pub surl: String,
pub furl: String,
}
impl PayuConfig {
pub fn from_env() -> Self {
Self {
merchant_key: std::env::var("PAYU_MERCHANT_KEY").unwrap_or_default(),
merchant_salt: std::env::var("PAYU_MERCHANT_SALT").unwrap_or_default(),
base_url: std::env::var("PAYU_BASE_URL")
.unwrap_or_else(|_| "https://test.payu.in".to_string()),
surl: std::env::var("PAYU_SURL")
.unwrap_or_else(|_| "/api/payments/payu/callback".to_string()),
furl: std::env::var("PAYU_FURL")
.unwrap_or_else(|_| "/api/payments/payu/callback".to_string()),
}
}
}
fn sha512_hex(input: &str) -> String {
let mut hasher = Sha512::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub fn request_hash(
config: &PayuConfig,
txnid: &str,
amount: &str,
productinfo: &str,
firstname: &str,
email: &str,
udf1: &str,
udf2: &str,
) -> String {
let seq = [
config.merchant_key.as_str(),
txnid,
amount,
productinfo,
firstname,
email,
udf1,
udf2,
"",
"",
"",
"",
"",
"",
"",
"",
config.merchant_salt.as_str(),
];
sha512_hex(&seq.join("|"))
}
/// Verify a PayU response hash. `status` is PayU's returned status string
/// (e.g. "success"/"failure") -- it is part of the hashed sequence, not
/// just metadata, per PayU's reverse-hash formula.
#[allow(clippy::too_many_arguments)]
pub fn verify_response_hash(
config: &PayuConfig,
status: &str,
txnid: &str,
amount: &str,
productinfo: &str,
firstname: &str,
email: &str,
udf1: &str,
udf2: &str,
received_hash: &str,
) -> bool {
let seq = [
config.merchant_salt.as_str(),
status,
"",
"",
"",
"",
"",
"",
"",
"",
udf2,
udf1,
email,
firstname,
productinfo,
amount,
txnid,
config.merchant_key.as_str(),
];
let expected = sha512_hex(&seq.join("|"));
expected.eq_ignore_ascii_case(received_hash)
}
pub fn generate_txnid() -> String {
uuid::Uuid::new_v4().to_string().replace('-', "")
}
/// Format an integer-paise amount as the decimal-rupee string PayU expects
/// in its hash and form fields (e.g. 9900 paise -> "99.00").
pub fn paise_to_rupee_string(paise: i32) -> String {
format!("{:.2}", paise as f64 / 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> PayuConfig {
PayuConfig {
merchant_key: "testkey".to_string(),
merchant_salt: "testsalt".to_string(),
base_url: "https://test.payu.in".to_string(),
surl: "/s".to_string(),
furl: "/f".to_string(),
}
}
#[test]
fn request_hash_is_deterministic_and_order_sensitive() {
let config = test_config();
let h1 = request_hash(&config, "txn1", "99.00", "Starter AI Credits", "John", "john@test.com", "u1", "u2");
let h2 = request_hash(&config, "txn1", "99.00", "Starter AI Credits", "John", "john@test.com", "u1", "u2");
assert_eq!(h1, h2);
let h3 = request_hash(&config, "txn2", "99.00", "Starter AI Credits", "John", "john@test.com", "u1", "u2");
assert_ne!(h1, h3, "different txnid must produce a different hash");
}
#[test]
fn response_hash_round_trips_with_request_hash_shape() {
let config = test_config();
// Build a response hash the way PayU would for a success callback,
// then confirm verify_response_hash accepts it.
let seq = [
config.merchant_salt.as_str(),
"success",
"",
"",
"",
"",
"",
"",
"",
"",
"u2",
"u1",
"john@test.com",
"John",
"Starter AI Credits",
"99.00",
"txn1",
config.merchant_key.as_str(),
];
let hash = sha512_hex(&seq.join("|"));
assert!(verify_response_hash(
&config, "success", "txn1", "99.00", "Starter AI Credits", "John", "john@test.com", "u1", "u2", &hash,
));
// Tampering with the amount must invalidate the hash.
assert!(!verify_response_hash(
&config, "success", "txn1", "999.00", "Starter AI Credits", "John", "john@test.com", "u1", "u2", &hash,
));
}
}