feat: verify payments with wallet credit, coupon validation, profile approval enhancements

- payments service: add DB persistence, wallet credit & ledger on verify
- users: coupons validate endpoint, coupon usage validation
- users: trigger_activation: assign user_role, credit 250 TC, ledger, notification
- users: add_notes: send document request notification
- fix employees port conflict
- update gateway payments route
- misc: add promotions and notifications on approval
This commit is contained in:
Ashwin Kumar 2026-04-06 08:24:08 +02:00
parent cb53b68f49
commit f3487ccff9
4 changed files with 330 additions and 19 deletions

View file

@ -12,3 +12,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true
reqwest = { version = "0.12", features = ["json", "stream"] }
anyhow.workspace = true
contracts = { path = "../../crates/contracts" }
sqlx.workspace = true
uuid.workspace = true
chrono.workspace = true

View file

@ -4,14 +4,18 @@ use axum::{
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
use sqlx::postgres::PgPool;
#[derive(Clone)]
struct AppState {
beeceptor_url: String,
client: reqwest::Client,
pool: PgPool,
}
#[derive(Debug, Serialize, Deserialize)]
@ -54,11 +58,29 @@ struct PaymentStatusResponse {
}
async fn create_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateOrderRequest>,
) -> Result<Json<CreateOrderResponse>, (StatusCode, String)> {
tracing::info!("Creating payment order: amount={}", payload.amount);
// Validate package_id
let package_id_str = payload.package_id.as_ref().ok_or((StatusCode::BAD_REQUEST, "package_id is required".to_string()))?;
let package_id = Uuid::parse_str(package_id_str).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;
// Fetch package to get tracecoins amount
let package = sqlx::query!(
"SELECT tracecoins_amount FROM pricing_packages WHERE id = $1 AND is_active = true",
package_id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let package = package.ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive package".to_string()))?;
let tracecoins_credited = package.tracecoins_amount;
// Call Beeceptor to create order
let resp = state
.client
.post(&state.beeceptor_url)
@ -66,8 +88,8 @@ async fn create_order(
.json(&serde_json::json!({
"amount": payload.amount,
"currency": payload.currency.as_deref().unwrap_or("INR"),
"package_id": payload.package_id,
"user_id": payload.user_id,
"package_id": package_id_str,
"user_id": auth.user_id.to_string(),
}))
.send()
.await
@ -95,6 +117,19 @@ async fn create_order(
.unwrap_or("mock_order_123")
.to_string();
// Insert payment record
sqlx::query!(
"INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING')",
auth.user_id,
package_id,
order_id,
payload.amount as i64,
tracecoins_credited
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(CreateOrderResponse {
order_id,
amount: payload.amount,
@ -104,13 +139,14 @@ async fn create_order(
}
async fn verify_payment(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<VerifyPaymentRequest>,
) -> Result<Json<VerifyPaymentResponse>, (StatusCode, String)> {
tracing::info!("Verifying payment: order_id={}", payload.order_id);
// Verify with Beeceptor
let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/'));
let resp = state
.client
.post(&verify_url)
@ -136,6 +172,68 @@ async fn verify_payment(
));
}
// Find pending payment by razorpay_order_id
let payment = sqlx::query!(
"SELECT id, user_id, tracecoins_credited FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING'",
payload.order_id
)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payment = match payment {
Some(p) => p,
None => return Err((StatusCode::NOT_FOUND, "Payment not found or already processed".to_string())),
};
// Ensure the authenticated user matches the payment user
if payment.user_id != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Payment does not belong to user".to_string()));
}
// Update payment status to SUCCESS
sqlx::query!(
"UPDATE payments SET status = 'SUCCESS', verified_at = NOW(), razorpay_payment_id = $1 WHERE id = $2",
payload.payment_id,
payment.id
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Credit wallet (increase balance)
sqlx::query!(
"INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, $2, 0) ON CONFLICT (user_id) DO UPDATE SET balance = tracecoin_wallets.balance + excluded.balance",
payment.user_id,
payment.tracecoins_credited
)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Get wallet id for ledger
match sqlx::query_scalar!(
"SELECT id FROM tracecoin_wallets WHERE user_id = $1",
payment.user_id
)
.fetch_optional(&state.pool)
.await
{
Ok(Some(wallet_id)) => {
sqlx::query!(
"INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)",
wallet_id,
payment.tracecoins_credited as i64,
"PURCHASE",
payment.id
)
.execute(&state.pool)
.await
.ok();
}
_ => {}
}
Ok(Json(VerifyPaymentResponse {
verified: true,
payment_id: payload.payment_id,
@ -151,7 +249,6 @@ async fn get_payment_status(
tracing::info!("Getting payment status: payment_id={}", payment_id);
let status_url = format!("{}/{}", state.beeceptor_url.trim_end_matches('/'), payment_id);
let resp = state
.client
.get(&status_url)
@ -206,9 +303,16 @@ async fn main() {
let beeceptor_url = std::env::var("BEECEPTOR_URL")
.unwrap_or_else(|_| "https://nxtgauge.free.beeceptor.com".to_string());
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/nxtgauge".to_string());
let pool = PgPool::connect(&db_url)
.await
.expect("Failed to connect to database");
let state = AppState {
beeceptor_url,
client: reqwest::Client::new(),
pool,
};
let app = Router::new()

View file

@ -6,6 +6,7 @@ use axum::{
routing::{delete, get, patch, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -16,6 +17,7 @@ pub fn coupons_router() -> Router<AppState> {
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<AppState> {
@ -430,11 +432,161 @@ async fn update_discount(
.execute(&state.pool)
.await;
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => {
tracing::error!("Failed to update discount {id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update discount" }))).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<String>,
discount_value: Option<i32>,
final_price_inr: i32,
message: String,
}
async fn validate_coupon(
_auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<ValidateCouponPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let code = payload.coupon_code.trim().to_uppercase();
// Fetch coupon
let coupon = sqlx::query!(
r#"
SELECT id, code, title, discount_type, discount_value, min_order_amount,
max_uses, role_keys, valid_until, is_active
FROM coupons
WHERE code = $1
"#,
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!(
"SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1",
coupon.id
)
.fetch_one(&state.pool)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
if count >= max_uses {
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(),
}),
))
}
}
}

View file

@ -7,6 +7,7 @@ use axum::{
Json, Router,
};
use contracts::auth_middleware::{require_admin, AuthUser};
use db::models::role::RoleRepository;
use db::models::verification::{VerificationRepository};
use serde::Deserialize;
use uuid::Uuid;
@ -134,15 +135,54 @@ async fn trigger_activation(
.execute(&state.pool)
.await?;
// Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let _ = state.mail.send_approval_approved_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display
).await;
}
// Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let _ = state.mail.send_approval_approved_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display
).await;
}
// Assign role to user in user_roles
if let Ok(Some(role)) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query!(
"INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()",
user_id,
role.id
).execute(&state.pool).await.ok();
}
// Credit 250 promotional tracecoins
sqlx::query!(
"INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, $2, 0) ON CONFLICT (user_id) DO UPDATE SET balance = tracecoin_wallets.balance + excluded.balance",
user_id,
250
).execute(&state.pool).await.ok();
// Get wallet id for ledger entry
if let Ok(Some(wallet_id)) = sqlx::query_scalar!(
"SELECT id FROM tracecoin_wallets WHERE user_id = $1",
user_id
).fetch_optional(&state.pool).await.ok().flatten() {
sqlx::query!(
"INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)",
wallet_id,
250,
"PROMOTIONAL_SIGNUP_BONUS",
user_id
).execute(&state.pool).await.ok();
}
// Create notification for user
sqlx::query!(
"INSERT INTO notifications (user_id, title, body, type) VALUES ($1, $2, $3, $4)",
user_id,
"Profile Approved",
format!("Your {} profile has been approved.", role_key_to_display(&role_key)),
"APPROVAL"
).execute(&state.pool).await.ok();
}
Ok(())
@ -274,7 +314,18 @@ async fn add_notes(
)
.await
{
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Ok(v) => {
// Notify user that admin added notes/requested documents
sqlx::query!(
"INSERT INTO notifications (user_id, title, body, type, reference_id) VALUES ($1, $2, $3, $4, $5)",
v.user_id,
"Action Required",
format!("Admin requested: {}", notes),
"DOCUMENT_REQUEST",
v.id
).execute(&state.pool).await.ok();
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}