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:
parent
cb53b68f49
commit
f3487ccff9
4 changed files with 330 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue